merge: 合并最新 main 到发货数据分支(保留线上修复 + 对齐工作平台规则)
以 main 的同步实现为底(超时重连、按 io_date 预载出库映射、逐包裹替换并保留人工字段、 未扫到旧记录保留待核、全表扫描),移植发货数据对齐工作平台的规则: - 出库映射预载改为富结构:单号/订单/单号+订单 三种定位,作废重出优先有效单,记录 io_date 作发货时间 - 订单元数据补查增加 状态(取消/拆分/合并) 与 线上发货 标记,替代原 LEFT JOIN;全表扫描后释放缓存 - 换单配对、出库单兜底、采购退货出库、组合件拆件、明细体积口径、仓库/名称清洗 - 按规则明确排除的包裹(取消/拆分/合并/线上发货)在本窗口内清除旧记录,其余未扫到记录仍按 main 逻辑保留 - @ 前缀:沿用 main 的同订单同日去重,剩余 @ 统一去掉 - 同步命令额外输出出库单兜底、采购退货条数(仅窗口模式返回) 验证:发货同步单元测试 29/29、发货页面测试 5/5 通过;本地清空 8.15-9.30 后全量同步 9.1-9.16, 与工作平台对比两边共有包裹 195,555,带单号漏拉仅 1 单(工作平台拉数后单号被改)。 BusinessDataPlacementTest(数据集数量写死 56,实际 66)在 main 上本就失败,与本次无关。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhry6eiuwf
This commit is contained in:
commit
8fba83d768
@ -65,10 +65,10 @@
|
||||
## 8. 构建与生产部署
|
||||
|
||||
- 普通代码发布由任务 Codex 在完成验证与合并后直接执行,不要求用户再次说“部署、上线”;用户明确要求仅本地开发、仅审查或暂不上线时遵循其范围。正式发布只使用已合并的最新 `main`。
|
||||
- Windows 开发机统一使用 `scripts/deploy-from-workstation.ps1`。服务器只运行开发机已构建的 `caiwu_akaunting:<commit>` 镜像,禁止在服务器执行 Composer、npm 或 Docker 构建。
|
||||
- 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`;其他迁移仍按第 6 节确认授权。不能因需要迁移开关就把镜像接入停给负责人。
|
||||
- 发布脚本检测到新增数据库迁移时,由 Codex 核对全部待执行迁移。仅涉及本任务共享镜像建表、字段、索引和同步登记的迁移,完成备份后自主使用 `-AllowMigrations`(macOS 为 `--allow-migrations`);其他迁移仍按第 6 节确认授权。不能因需要迁移开关就把镜像接入停给负责人。
|
||||
- 容器内 Laravel 命令使用 `www-data`。发布后只需确认部署 SHA、应用容器健康、受影响的真实页面/接口、迁移是否执行,以及当前和上一版镜像可回滚。
|
||||
- 程序静态资源随应用镜像同源发布;七牛只存用户上传的业务图片、票据和附件。
|
||||
- 只有本地初始化、服务器维护、生产发布或数据库迁移任务需要读取 `docs/开发与部署SOP.md` 的对应章节;普通功能开发无需加载整份 SOP。
|
||||
|
||||
@ -145,7 +145,10 @@ docker compose -f docker-compose.local.yml start
|
||||
|
||||
## 修改后的生效方式
|
||||
|
||||
- PHP、配置、模块、页面模板和路由目录已经挂载到容器,通常清理缓存或重启应用即可;
|
||||
- PHP、配置、模块、页面模板和路由目录已经挂载到容器;开发环境关闭 PHP OPcache 和 Blade 编译缓存,刷新浏览器即可加载最新源码,无需重启 PHP 服务;
|
||||
- 开发环境启动时只执行 `optimize:clear`,不会生成 Laravel 的配置、路由或视图缓存;生产环境仍保留原有缓存构建机制;
|
||||
- 本地 Compose 会同时启动 `frontend` watcher。修改 `resources/assets` 下的 Vue、JavaScript 或 SCSS 后,Laravel Mix 会通过轮询自动重新编译,Apache 使用共享静态资源卷提供最新文件;浏览器刷新即可看到结果。
|
||||
- 如果 watcher 构建失败,查看 `docker compose -f docker-compose.local.yml logs -f frontend`;修复源码后它会继续监听并重新编译。
|
||||
- Composer 依赖、前端依赖、Dockerfile 或构建资源发生变化时,重新执行 `bash deploy/local-init.sh`,脚本会自动重建;
|
||||
- 修改 `runtime/.env` 后,执行:
|
||||
|
||||
@ -154,6 +157,8 @@ docker compose -f docker-compose.local.yml restart app
|
||||
docker compose -f docker-compose.local.yml exec -u www-data app php artisan optimize:clear
|
||||
```
|
||||
|
||||
停止本地环境时,`app`、`frontend` 和 `mysql` 会一起停止;再次运行初始化脚本会恢复 PHP 服务和前端 watcher。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- 新电脑接续指南:[docs/新电脑开发接续指南.md](docs/新电脑开发接续指南.md)
|
||||
|
||||
120
app/Business/CourierPayableSummaryFilter.php
Normal file
120
app/Business/CourierPayableSummaryFilter.php
Normal file
@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class CourierPayableSummaryFilter
|
||||
{
|
||||
/** @return array{date:string,person:string,courier:string,keyword:string} */
|
||||
public function fromRequest(Request $request): array
|
||||
{
|
||||
$clean = static function (string $key, int $maxLength) use ($request): string {
|
||||
$value = $request->query($key, '');
|
||||
|
||||
if (! is_scalar($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return mb_substr(trim((string) $value), 0, $maxLength);
|
||||
};
|
||||
|
||||
return [
|
||||
'date' => $clean('date', 50),
|
||||
'person' => $clean('person', 100),
|
||||
'courier' => $clean('courier', 100),
|
||||
'keyword' => $clean('keyword', 191),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array{date:string,person:string,courier:string,keyword:string} $filters */
|
||||
public function hasFilters(array $filters): bool
|
||||
{
|
||||
return collect($filters)->contains(static fn (string $value): bool => $value !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed> $rows
|
||||
* @param list<string> $headers
|
||||
* @param array{date:string,person:string,courier:string,keyword:string} $filters
|
||||
* @return Collection<int, mixed>
|
||||
*/
|
||||
public function apply(Collection $rows, array $headers, array $filters): Collection
|
||||
{
|
||||
if (! $this->hasFilters($filters)) {
|
||||
return $rows;
|
||||
}
|
||||
|
||||
$dateColumn = $this->columnIndex($headers, ['日期', '结算月份', '结算月', '月份']);
|
||||
$personColumn = $this->columnIndex($headers, ['负责人', '责任人']);
|
||||
$courierColumn = $this->columnIndex($headers, ['快递公司', '快递名称', '快递名', '快递']);
|
||||
|
||||
return $rows->filter(function (mixed $row) use ($filters, $dateColumn, $personColumn, $courierColumn): bool {
|
||||
if (! is_array($row)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->columnMatches($row, $dateColumn, $filters['date'])
|
||||
|| ! $this->columnMatches($row, $personColumn, $filters['person'])
|
||||
|| ! $this->columnMatches($row, $courierColumn, $filters['courier'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($filters['keyword'] === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($row as $value) {
|
||||
if ($this->contains($value, $filters['keyword'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param list<string> $headers */
|
||||
public function dateColumnIndex(array $headers): ?int
|
||||
{
|
||||
return $this->columnIndex($headers, ['日期', '结算月份', '结算月', '月份']);
|
||||
}
|
||||
|
||||
/** @param list<string> $headers */
|
||||
private function columnIndex(array $headers, array $aliases): ?int
|
||||
{
|
||||
$normalizedAliases = array_map(
|
||||
static fn (string $alias): string => mb_strtolower(preg_replace('/[\\s ]+/u', '', $alias) ?? $alias),
|
||||
$aliases,
|
||||
);
|
||||
|
||||
foreach ($headers as $index => $header) {
|
||||
$normalizedHeader = mb_strtolower(preg_replace('/[\\s ]+/u', '', trim((string) $header)) ?? trim((string) $header));
|
||||
|
||||
if (in_array($normalizedHeader, $normalizedAliases, true)) {
|
||||
return (int) $index;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function columnMatches(array $row, ?int $column, string $needle): bool
|
||||
{
|
||||
if ($needle === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $column !== null && $this->contains($row[$column] ?? '', $needle);
|
||||
}
|
||||
|
||||
private function contains(mixed $value, string $needle): bool
|
||||
{
|
||||
if (! is_scalar($value) && ! $value instanceof \Stringable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return mb_stripos((string) $value, $needle, 0, 'UTF-8') !== false;
|
||||
}
|
||||
}
|
||||
264
app/Business/CourierPayableSummaryStore.php
Normal file
264
app/Business/CourierPayableSummaryStore.php
Normal file
@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\StringValueBinder;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class CourierPayableSummaryStore
|
||||
{
|
||||
public const PAGES = [
|
||||
'freight-difference' => '运费差',
|
||||
'other-surcharge-summary' => '其他附加费汇总',
|
||||
'return-fee' => '退件费',
|
||||
];
|
||||
|
||||
private const MAX_COLUMNS = 200;
|
||||
|
||||
private const MAX_ROWS_BY_PAGE = [
|
||||
'freight-difference' => 500_000,
|
||||
'other-surcharge-summary' => 500_000,
|
||||
'return-fee' => 500_000,
|
||||
];
|
||||
|
||||
private const MAX_CELL_LENGTH = 10000;
|
||||
|
||||
public function title(string $page): string
|
||||
{
|
||||
abort_unless(isset(self::PAGES[$page]), 404);
|
||||
|
||||
return self::PAGES[$page];
|
||||
}
|
||||
|
||||
public function maxRows(string $page): int
|
||||
{
|
||||
$this->title($page);
|
||||
|
||||
return self::MAX_ROWS_BY_PAGE[$page];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{version:int,headers:list<string>,rows:list<list<string>>,source_name:string,imported_at:string,imported_by:int|null}|null
|
||||
*/
|
||||
public function snapshot(int $companyId, string $page): ?array
|
||||
{
|
||||
$this->title($page);
|
||||
$path = $this->path($companyId, $page);
|
||||
$disk = Storage::disk('uploads');
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$snapshot = json_decode($disk->get($path), true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
throw new RuntimeException('courier_payable_summary_unreadable', previous: $exception);
|
||||
}
|
||||
|
||||
if (! is_array($snapshot)
|
||||
|| ! is_array($snapshot['headers'] ?? null)
|
||||
|| ! is_array($snapshot['rows'] ?? null)) {
|
||||
throw new RuntimeException('courier_payable_summary_invalid');
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/** @return array{rows:int,columns:int} */
|
||||
public function import(UploadedFile $file, int $companyId, string $page, ?int $userId): array
|
||||
{
|
||||
$this->title($page);
|
||||
$valueBinder = Cell::getValueBinder();
|
||||
Cell::setValueBinder(new StringValueBinder());
|
||||
|
||||
try {
|
||||
$reader = IOFactory::createReaderForFile($file->getRealPath());
|
||||
$reader->setReadDataOnly(true);
|
||||
$spreadsheet = $reader->load($file->getRealPath());
|
||||
[$headers, $rows] = $this->readSpreadsheet($spreadsheet, $page);
|
||||
} catch (ValidationException $exception) {
|
||||
throw $exception;
|
||||
} catch (Throwable $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
|
||||
]);
|
||||
} finally {
|
||||
Cell::setValueBinder($valueBinder);
|
||||
|
||||
if (isset($spreadsheet)) {
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'version' => 1,
|
||||
'headers' => $headers,
|
||||
'rows' => $rows,
|
||||
'source_name' => mb_substr($file->getClientOriginalName(), 0, 255),
|
||||
'imported_at' => now()->toIso8601String(),
|
||||
'imported_by' => $userId,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR);
|
||||
|
||||
$disk = Storage::disk('uploads');
|
||||
$path = $this->path($companyId, $page);
|
||||
$previousPath = $this->previousPath($companyId, $page);
|
||||
|
||||
if ($disk->exists($path)) {
|
||||
$previous = $disk->get($path);
|
||||
|
||||
if (! $disk->put($previousPath, $previous)) {
|
||||
throw new RuntimeException('无法备份当前汇总数据。');
|
||||
}
|
||||
}
|
||||
|
||||
if (! $disk->put($path, $payload)) {
|
||||
throw new RuntimeException('无法保存汇总数据。');
|
||||
}
|
||||
|
||||
return ['rows' => count($rows), 'columns' => count($headers)];
|
||||
}
|
||||
|
||||
public function path(int $companyId, string $page): string
|
||||
{
|
||||
$this->title($page);
|
||||
|
||||
return "courier-payable-summaries/{$companyId}/{$page}.json";
|
||||
}
|
||||
|
||||
private function previousPath(int $companyId, string $page): string
|
||||
{
|
||||
return "courier-payable-summaries/{$companyId}/{$page}.previous.json";
|
||||
}
|
||||
|
||||
/** @return array{0:list<string>,1:list<list<string>>} */
|
||||
private function readSpreadsheet($spreadsheet, string $page): array
|
||||
{
|
||||
$maxRows = $this->maxRows($page);
|
||||
|
||||
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
|
||||
$rawRows = $worksheet->toArray(null, false, true, false);
|
||||
$headerIndex = $this->headerIndex($rawRows);
|
||||
|
||||
if ($headerIndex === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lastColumn = $this->lastUsedColumn($rawRows, $headerIndex);
|
||||
|
||||
if ($lastColumn >= self::MAX_COLUMNS) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '单个汇总表最多支持 ' . self::MAX_COLUMNS . ' 列。',
|
||||
]);
|
||||
}
|
||||
|
||||
$headers = [];
|
||||
for ($column = 0; $column <= $lastColumn; $column++) {
|
||||
$label = $this->cell($rawRows[$headerIndex][$column] ?? null);
|
||||
$headers[] = $label !== '' ? $label : '未命名列' . ($column + 1);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach (array_slice($rawRows, $headerIndex + 1) as $rawRow) {
|
||||
$row = [];
|
||||
$hasValue = false;
|
||||
|
||||
for ($column = 0; $column <= $lastColumn; $column++) {
|
||||
$value = $this->cell($rawRow[$column] ?? null);
|
||||
$row[] = $value;
|
||||
$hasValue = $hasValue || $value !== '';
|
||||
}
|
||||
|
||||
if (! $hasValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = $row;
|
||||
|
||||
if (count($rows) > $maxRows) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '单次最多导入 ' . number_format($maxRows) . ' 条汇总数据,请拆分文件后重试。',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
throw ValidationException::withMessages(['import' => '表格中没有可导入的数据行。']);
|
||||
}
|
||||
|
||||
return [$headers, $rows];
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages(['import' => '没有找到有效的表头和数据。']);
|
||||
}
|
||||
|
||||
private function headerIndex(array $rows): ?int
|
||||
{
|
||||
$firstNonEmpty = null;
|
||||
|
||||
foreach (array_slice($rows, 0, 20, true) as $index => $row) {
|
||||
$count = count(array_filter(
|
||||
$row,
|
||||
fn (mixed $value): bool => $this->cell($value) !== '',
|
||||
));
|
||||
|
||||
if ($count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$firstNonEmpty ??= $index;
|
||||
|
||||
if ($count >= 2) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
|
||||
return $firstNonEmpty;
|
||||
}
|
||||
|
||||
private function lastUsedColumn(array $rows, int $headerIndex): int
|
||||
{
|
||||
$lastColumn = -1;
|
||||
|
||||
foreach (array_slice($rows, $headerIndex) as $row) {
|
||||
foreach ($row as $column => $value) {
|
||||
if ($this->cell($value) !== '') {
|
||||
$lastColumn = max($lastColumn, (int) $column);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return max($lastColumn, 0);
|
||||
}
|
||||
|
||||
private function cell(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? '是' : '否';
|
||||
}
|
||||
|
||||
if (! is_scalar($value) && ! $value instanceof \Stringable) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
|
||||
if (mb_strlen($value) > self::MAX_CELL_LENGTH) {
|
||||
throw ValidationException::withMessages(['import' => '单元格内容过长,请精简后重试。']);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
151
app/Business/CourierSurchargeAuditLogRepository.php
Normal file
151
app/Business/CourierSurchargeAuditLogRepository.php
Normal file
@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\CourierSurchargeAuditLog;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 快递附加费维护审计日志的固定入口。
|
||||
*
|
||||
* 日志只保存当前公司、操作人和报价/类型的安全字段快照,不保存密码、
|
||||
* Token、连接串或原始请求内容。
|
||||
*/
|
||||
class CourierSurchargeAuditLogRepository
|
||||
{
|
||||
/** @var array<string,string> */
|
||||
private const FIELD_LABELS = [
|
||||
'courier' => '快递名称',
|
||||
'scope' => '快递归属',
|
||||
'reconciler' => '对账人',
|
||||
'supplier' => '供应商',
|
||||
'types' => '附加费类型',
|
||||
'mode' => '结算方式',
|
||||
'standard' => '计价标准',
|
||||
'condition' => '生效条件',
|
||||
'effective_at' => '生效日期',
|
||||
'remark' => '备注',
|
||||
'status' => '状态',
|
||||
'name' => '类型名称',
|
||||
];
|
||||
|
||||
/** @var array<string,string> */
|
||||
private const ACTION_LABELS = [
|
||||
CourierSurchargeAuditLog::ACTION_CREATED => '新增',
|
||||
CourierSurchargeAuditLog::ACTION_UPDATED => '更改',
|
||||
CourierSurchargeAuditLog::ACTION_ACTIVATED => '启用',
|
||||
CourierSurchargeAuditLog::ACTION_DEACTIVATED => '停用',
|
||||
];
|
||||
|
||||
/** @var array<string,string> */
|
||||
private const TARGET_LABELS = [
|
||||
CourierSurchargeAuditLog::TARGET_QUOTE => '报价规则',
|
||||
CourierSurchargeAuditLog::TARGET_TYPE => '附加费类型',
|
||||
];
|
||||
|
||||
public function ready(): bool
|
||||
{
|
||||
try {
|
||||
return Schema::hasTable((new CourierSurchargeAuditLog())->getTable());
|
||||
} catch (QueryException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $changes */
|
||||
public function record(
|
||||
string $action,
|
||||
string $targetType,
|
||||
?int $targetId,
|
||||
string $targetLabel,
|
||||
array $changes,
|
||||
int $actorId,
|
||||
): CourierSurchargeAuditLog {
|
||||
abort_unless($this->ready(), 503, '附加费操作日志数据表尚未完成迁移。');
|
||||
abort_unless(array_key_exists($action, self::ACTION_LABELS), 422, '附加费日志动作不合法。');
|
||||
abort_unless(array_key_exists($targetType, self::TARGET_LABELS), 422, '附加费日志对象不合法。');
|
||||
|
||||
return CourierSurchargeAuditLog::query()->create([
|
||||
'company_id' => company_id(),
|
||||
'actor_id' => $actorId ?: null,
|
||||
'action' => $action,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $targetId,
|
||||
'target_label' => mb_substr(trim($targetLabel), 0, 191),
|
||||
'changes' => $changes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int,array<string,mixed>>
|
||||
*/
|
||||
public function recent(int $limit = 50): Collection
|
||||
{
|
||||
if (! $this->ready()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
try {
|
||||
return CourierSurchargeAuditLog::query()
|
||||
->with('actor')
|
||||
->orderByDesc('id')
|
||||
->limit(max(1, min($limit, 200)))
|
||||
->get()
|
||||
->map(fn (CourierSurchargeAuditLog $log): array => [
|
||||
'id' => $log->id,
|
||||
'occurred_at' => optional($log->created_at)->format('Y-m-d H:i:s') ?: '',
|
||||
'actor_name' => $log->actor?->name ?: '系统',
|
||||
'action' => $log->action,
|
||||
'action_label' => self::ACTION_LABELS[$log->action] ?? $log->action,
|
||||
'target_type' => $log->target_type,
|
||||
'target_id' => $log->target_id,
|
||||
'target_type_label' => self::TARGET_LABELS[$log->target_type] ?? $log->target_type,
|
||||
'target_label' => $log->target_label,
|
||||
'changes_label' => $this->changesLabel($log),
|
||||
])
|
||||
->values();
|
||||
} catch (QueryException) {
|
||||
return collect();
|
||||
}
|
||||
}
|
||||
|
||||
private function changesLabel(CourierSurchargeAuditLog $log): string
|
||||
{
|
||||
$changes = (array) $log->changes;
|
||||
if ($log->action === CourierSurchargeAuditLog::ACTION_CREATED) {
|
||||
return '新增记录';
|
||||
}
|
||||
|
||||
if (in_array($log->action, [CourierSurchargeAuditLog::ACTION_ACTIVATED, CourierSurchargeAuditLog::ACTION_DEACTIVATED], true)) {
|
||||
$before = data_get($changes, 'before.status');
|
||||
$after = data_get($changes, 'after.status');
|
||||
return sprintf('状态:%s → %s', $before ?: '—', $after ?: '—');
|
||||
}
|
||||
|
||||
$before = (array) ($changes['before'] ?? []);
|
||||
$after = (array) ($changes['after'] ?? []);
|
||||
$parts = [];
|
||||
foreach (array_unique(array_merge(array_keys($before), array_keys($after))) as $field) {
|
||||
$old = $this->valueLabel($before[$field] ?? null);
|
||||
$new = $this->valueLabel($after[$field] ?? null);
|
||||
if ($old !== $new) {
|
||||
$parts[] = sprintf('%s:%s → %s', self::FIELD_LABELS[$field] ?? $field, $old, $new);
|
||||
}
|
||||
}
|
||||
|
||||
return $parts === [] ? '未改变字段' : implode(';', $parts);
|
||||
}
|
||||
|
||||
private function valueLabel(mixed $value): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return implode('、', array_map(static fn (mixed $item): string => (string) $item, $value));
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
|
||||
return $value === '' ? '—' : $value;
|
||||
}
|
||||
}
|
||||
@ -2,10 +2,12 @@
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\CourierSurchargeAuditLog;
|
||||
use App\Models\Business\CourierSurchargeQuote;
|
||||
use App\Models\Business\CourierSurchargeType;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
@ -28,14 +30,21 @@ class CourierSurchargeQuoteRepository
|
||||
|
||||
public const SCOPES = ['自有快递', '代发仓'];
|
||||
|
||||
public function __construct(private readonly CourierSurchargeTypeRepository $types)
|
||||
public function __construct(
|
||||
private readonly CourierSurchargeTypeRepository $types,
|
||||
private readonly CourierSurchargeAuditLogRepository $audits,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function ready(): bool
|
||||
{
|
||||
try {
|
||||
return Schema::hasTable((new CourierSurchargeQuote())->getTable()) && $this->types->ready();
|
||||
$table = (new CourierSurchargeQuote())->getTable();
|
||||
|
||||
return Schema::hasTable($table)
|
||||
&& Schema::hasColumn($table, 'supplier')
|
||||
&& $this->types->ready();
|
||||
} catch (QueryException) {
|
||||
return false;
|
||||
}
|
||||
@ -58,8 +67,7 @@ class CourierSurchargeQuoteRepository
|
||||
|
||||
try {
|
||||
$all = CourierSurchargeQuote::query()
|
||||
->orderByDesc('effective_at')
|
||||
->orderByDesc('id')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
} catch (QueryException) {
|
||||
return [
|
||||
@ -71,7 +79,7 @@ class CourierSurchargeQuoteRepository
|
||||
}
|
||||
|
||||
$filtered = $all->filter(function (CourierSurchargeQuote $quote) use ($filters): bool {
|
||||
foreach (['courier', 'scope', 'reconciler', 'mode'] as $key) {
|
||||
foreach (['courier', 'scope', 'reconciler', 'supplier', 'mode'] as $key) {
|
||||
if (($filters[$key] ?? '') !== '' && (string) $quote->{$key} !== $filters[$key]) {
|
||||
return false;
|
||||
}
|
||||
@ -91,6 +99,7 @@ class CourierSurchargeQuoteRepository
|
||||
$quote->courier,
|
||||
$quote->scope,
|
||||
$quote->reconciler,
|
||||
(string) $quote->supplier,
|
||||
$quote->typeLabel(),
|
||||
$quote->mode,
|
||||
$quote->standard,
|
||||
@ -134,6 +143,7 @@ class CourierSurchargeQuoteRepository
|
||||
'modes' => collect(self::MODES),
|
||||
'scopes' => self::SCOPES,
|
||||
'reconcilers' => $all->pluck('reconciler')->filter()->unique()->sort()->values(),
|
||||
'suppliers' => $all->pluck('supplier')->filter()->unique()->sort()->values(),
|
||||
'statuses' => ['已启用', '已停用'],
|
||||
],
|
||||
];
|
||||
@ -151,30 +161,147 @@ class CourierSurchargeQuoteRepository
|
||||
return $this->types->activeNames();
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function findImportDuplicate(array $data, bool $lockForUpdate = false): ?CourierSurchargeQuote
|
||||
{
|
||||
$query = CourierSurchargeQuote::query()
|
||||
->where('company_id', company_id())
|
||||
->where('courier', trim((string) $data['courier']))
|
||||
->where('scope', trim((string) $data['scope']))
|
||||
->where('reconciler', trim((string) $data['reconciler']))
|
||||
->where('mode', trim((string) $data['mode']))
|
||||
->where('condition', trim((string) $data['condition']))
|
||||
->whereDate('effective_at', (string) $data['effective_at']);
|
||||
|
||||
$supplier = trim((string) ($data['supplier'] ?? ''));
|
||||
$supplier === '' ? $query->whereNull('supplier') : $query->where('supplier', $supplier);
|
||||
|
||||
if ($lockForUpdate) {
|
||||
$query->lockForUpdate();
|
||||
}
|
||||
|
||||
$types = $this->normalizedTypes((array) $data['types']);
|
||||
|
||||
return $query->get()->first(
|
||||
fn (CourierSurchargeQuote $quote): bool => $this->normalizedTypes((array) $quote->types) === $types,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function importIdentity(array $data): string
|
||||
{
|
||||
return hash('sha256', json_encode([
|
||||
trim((string) ($data['courier'] ?? '')),
|
||||
trim((string) ($data['scope'] ?? '')),
|
||||
trim((string) ($data['reconciler'] ?? '')),
|
||||
trim((string) ($data['supplier'] ?? '')),
|
||||
$this->normalizedTypes((array) ($data['types'] ?? [])),
|
||||
trim((string) ($data['mode'] ?? '')),
|
||||
trim((string) ($data['effective_at'] ?? '')),
|
||||
trim((string) ($data['condition'] ?? '')),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function create(array $data, int $userId): CourierSurchargeQuote
|
||||
{
|
||||
return CourierSurchargeQuote::query()->create($this->attributes($data, $userId) + [
|
||||
'status' => CourierSurchargeQuote::STATUS_ACTIVE,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
return DB::transaction(function () use ($data, $userId): CourierSurchargeQuote {
|
||||
$quote = CourierSurchargeQuote::query()->create($this->attributes($data, $userId) + [
|
||||
'status' => CourierSurchargeQuote::STATUS_ACTIVE,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
$this->audits->record(
|
||||
CourierSurchargeAuditLog::ACTION_CREATED,
|
||||
CourierSurchargeAuditLog::TARGET_QUOTE,
|
||||
(int) $quote->id,
|
||||
$this->targetLabel($quote),
|
||||
['after' => $this->snapshot($quote)],
|
||||
$userId,
|
||||
);
|
||||
|
||||
return $quote;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function update(CourierSurchargeQuote $quote, array $data, int $userId): CourierSurchargeQuote
|
||||
{
|
||||
$quote->update($this->attributes($data, $userId));
|
||||
return DB::transaction(function () use ($quote, $data, $userId): CourierSurchargeQuote {
|
||||
$before = $this->snapshot($quote);
|
||||
$quote->update($this->attributes($data, $userId));
|
||||
$fresh = $quote->fresh();
|
||||
|
||||
return $quote->fresh();
|
||||
$this->audits->record(
|
||||
CourierSurchargeAuditLog::ACTION_UPDATED,
|
||||
CourierSurchargeAuditLog::TARGET_QUOTE,
|
||||
(int) $fresh->id,
|
||||
$this->targetLabel($fresh),
|
||||
['before' => $before, 'after' => $this->snapshot($fresh)],
|
||||
$userId,
|
||||
);
|
||||
|
||||
return $fresh;
|
||||
});
|
||||
}
|
||||
|
||||
public function setStatus(CourierSurchargeQuote $quote, string $status, int $userId): CourierSurchargeQuote
|
||||
{
|
||||
abort_unless(in_array($status, [CourierSurchargeQuote::STATUS_ACTIVE, CourierSurchargeQuote::STATUS_INACTIVE], true), 422, '报价规则状态不合法。');
|
||||
|
||||
$quote->forceFill(['status' => $status, 'updated_by' => $userId])->save();
|
||||
return DB::transaction(function () use ($quote, $status, $userId): CourierSurchargeQuote {
|
||||
$oldStatus = $quote->status;
|
||||
$quote->forceFill(['status' => $status, 'updated_by' => $userId])->save();
|
||||
$fresh = $quote->fresh();
|
||||
|
||||
return $quote->fresh();
|
||||
if ($oldStatus !== $status) {
|
||||
$this->audits->record(
|
||||
$status === CourierSurchargeQuote::STATUS_ACTIVE
|
||||
? CourierSurchargeAuditLog::ACTION_ACTIVATED
|
||||
: CourierSurchargeAuditLog::ACTION_DEACTIVATED,
|
||||
CourierSurchargeAuditLog::TARGET_QUOTE,
|
||||
(int) $fresh->id,
|
||||
$this->targetLabel($fresh),
|
||||
[
|
||||
'before' => ['status' => $this->statusLabel($oldStatus)],
|
||||
'after' => ['status' => $this->statusLabel($status)],
|
||||
],
|
||||
$userId,
|
||||
);
|
||||
}
|
||||
|
||||
return $fresh;
|
||||
});
|
||||
}
|
||||
|
||||
private function targetLabel(CourierSurchargeQuote $quote): string
|
||||
{
|
||||
$type = $quote->typeLabel();
|
||||
|
||||
return trim(sprintf('%s%s', $quote->courier, $type === '' ? '' : ' · ' . $type));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function snapshot(CourierSurchargeQuote $quote): array
|
||||
{
|
||||
return [
|
||||
'courier' => $quote->courier,
|
||||
'scope' => $quote->scope,
|
||||
'reconciler' => $quote->reconciler,
|
||||
'supplier' => $quote->supplier,
|
||||
'types' => array_values((array) $quote->types),
|
||||
'mode' => $quote->mode,
|
||||
'standard' => $quote->standard,
|
||||
'condition' => $quote->condition,
|
||||
'effective_at' => optional($quote->effective_at)->format('Y-m-d'),
|
||||
'status' => $this->statusLabel($quote->status),
|
||||
'remark' => $quote->remark,
|
||||
];
|
||||
}
|
||||
|
||||
private function statusLabel(?string $status): string
|
||||
{
|
||||
return $status === CourierSurchargeQuote::STATUS_ACTIVE ? '已启用' : '已停用';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
@ -185,6 +312,7 @@ class CourierSurchargeQuoteRepository
|
||||
'courier' => trim((string) $data['courier']),
|
||||
'scope' => trim((string) $data['scope']),
|
||||
'reconciler' => trim((string) $data['reconciler']),
|
||||
'supplier' => trim((string) ($data['supplier'] ?? '')) ?: null,
|
||||
'types' => array_values(array_unique(array_map('strval', $data['types']))),
|
||||
'mode' => trim((string) $data['mode']),
|
||||
'standard' => trim((string) $data['standard']),
|
||||
@ -195,6 +323,18 @@ class CourierSurchargeQuoteRepository
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int,mixed> $types @return list<string> */
|
||||
private function normalizedTypes(array $types): array
|
||||
{
|
||||
$types = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $type): string => trim((string) $type),
|
||||
$types,
|
||||
))));
|
||||
sort($types, SORT_STRING);
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function present(CourierSurchargeQuote $quote): array
|
||||
{
|
||||
@ -204,6 +344,7 @@ class CourierSurchargeQuoteRepository
|
||||
'courier' => $quote->courier,
|
||||
'scope' => $quote->scope,
|
||||
'reconciler' => $quote->reconciler,
|
||||
'supplier' => $quote->supplier,
|
||||
'types' => array_values((array) $quote->types),
|
||||
'type' => $quote->typeLabel(),
|
||||
'mode' => $quote->mode,
|
||||
@ -227,6 +368,7 @@ class CourierSurchargeQuoteRepository
|
||||
'modes' => collect(self::MODES),
|
||||
'scopes' => self::SCOPES,
|
||||
'reconcilers' => collect(),
|
||||
'suppliers' => collect(),
|
||||
'statuses' => ['已启用', '已停用'],
|
||||
];
|
||||
}
|
||||
|
||||
386
app/Business/CourierSurchargeQuoteWorkbookImporter.php
Normal file
386
app/Business/CourierSurchargeQuoteWorkbookImporter.php
Normal file
@ -0,0 +1,386 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\CourierSurchargeQuote;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Throwable;
|
||||
|
||||
class CourierSurchargeQuoteWorkbookImporter
|
||||
{
|
||||
public const MAX_ROWS = 500;
|
||||
|
||||
public const DUPLICATE_SKIP = 'skip';
|
||||
|
||||
public const DUPLICATE_UPDATE = 'update';
|
||||
|
||||
public const HEADERS = [
|
||||
'快递名称',
|
||||
'快递归属',
|
||||
'对账人',
|
||||
'供应商',
|
||||
'附加费类型',
|
||||
'结算方式',
|
||||
'计价标准',
|
||||
'生效条件',
|
||||
'生效日期',
|
||||
'状态',
|
||||
'备注',
|
||||
];
|
||||
|
||||
public function __construct(private readonly CourierSurchargeQuoteRepository $quotes)
|
||||
{
|
||||
}
|
||||
|
||||
public function template(): StreamedResponse
|
||||
{
|
||||
$types = $this->quotes->activeTypeNames();
|
||||
|
||||
return response()->streamDownload(function () use ($types): void {
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('附加费报价');
|
||||
$sheet->fromArray(self::HEADERS, null, 'A1');
|
||||
|
||||
$lastColumn = Coordinate::stringFromColumnIndex(count(self::HEADERS));
|
||||
$headerRange = 'A1:' . $lastColumn . '1';
|
||||
$sheet->getStyle($headerRange)->applyFromArray([
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'D8D2C7']]],
|
||||
]);
|
||||
$sheet->getRowDimension(1)->setRowHeight(24);
|
||||
$sheet->freezePane('A2');
|
||||
$sheet->setAutoFilter($headerRange);
|
||||
|
||||
$widths = [18, 14, 14, 20, 24, 18, 30, 32, 14, 12, 30];
|
||||
foreach ($widths as $index => $width) {
|
||||
$column = Coordinate::stringFromColumnIndex($index + 1);
|
||||
$sheet->getColumnDimension($column)->setWidth($width);
|
||||
$sheet->getStyle($column . '2:' . $column . (self::MAX_ROWS + 1))
|
||||
->getNumberFormat()
|
||||
->setFormatCode('@');
|
||||
}
|
||||
|
||||
$instructions = $spreadsheet->createSheet();
|
||||
$instructions->setTitle('填写说明');
|
||||
$instructions->fromArray([
|
||||
['项目', '填写要求'],
|
||||
['必填字段', '快递名称、快递归属、对账人、附加费类型、结算方式、计价标准、生效条件、生效日期、状态。'],
|
||||
['供应商', '选填;没有供应商时保持空白。'],
|
||||
['附加费类型', '多种类型使用“、”分隔;只能填写当前已启用类型。当前可选:' . implode('、', $types)],
|
||||
['快递归属', '自有快递 或 代发仓。'],
|
||||
['结算方式', implode('、', CourierSurchargeQuoteRepository::MODES)],
|
||||
['生效日期', '使用 YYYY-MM-DD,例如 2026-09-22。'],
|
||||
['状态', '已启用 或 已停用;也可填写 active 或 inactive。'],
|
||||
['重复规则', '预览后必须明确选择跳过已有规则或更新已有规则,系统不会静默覆盖。'],
|
||||
['单次上限', '最多 ' . self::MAX_ROWS . ' 条非空数据。'],
|
||||
], null, 'A1');
|
||||
$instructions->getStyle('A1:B1')->applyFromArray([
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
|
||||
]);
|
||||
$instructions->getColumnDimension('A')->setWidth(18);
|
||||
$instructions->getColumnDimension('B')->setWidth(90);
|
||||
$instructions->getStyle('A1:B10')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER)->setWrapText(true);
|
||||
foreach (range(1, 10) as $row) {
|
||||
$instructions->getRowDimension($row)->setRowHeight($row === 1 ? 24 : 34);
|
||||
}
|
||||
|
||||
$spreadsheet->setActiveSheetIndex(0);
|
||||
(new Xlsx($spreadsheet))->save('php://output');
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}, '附加费报价导入模板_' . now()->format('Ymd') . '.xlsx', [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* rows:list<array{line:int,data:array<string,mixed>,display:array<string,string>,errors:list<string>,result:string,result_label:string,existing_id:?int}>,
|
||||
* summary:array{total:int,valid:int,new:int,duplicate:int,invalid:int}
|
||||
* }
|
||||
*/
|
||||
public function preview(UploadedFile $file): array
|
||||
{
|
||||
$extension = mb_strtolower($file->getClientOriginalExtension());
|
||||
if (! in_array($extension, ['xlsx', 'xls', 'csv'], true)) {
|
||||
throw ValidationException::withMessages(['import' => '仅支持 xlsx、xls 或 csv 文件。']);
|
||||
}
|
||||
|
||||
try {
|
||||
$reader = IOFactory::createReader(match ($extension) {
|
||||
'xlsx' => 'Xlsx',
|
||||
'xls' => 'Xls',
|
||||
'csv' => 'Csv',
|
||||
});
|
||||
$reader->setReadDataOnly(true);
|
||||
$spreadsheet = $reader->load($file->getRealPath());
|
||||
$rows = $spreadsheet->getActiveSheet()->toArray(null, false, false, false);
|
||||
} catch (Throwable) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
|
||||
]);
|
||||
} finally {
|
||||
if (isset($spreadsheet)) {
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
}
|
||||
|
||||
$headers = array_map(fn (mixed $value): string => $this->text($value), $rows[0] ?? []);
|
||||
while ($headers !== [] && end($headers) === '') {
|
||||
array_pop($headers);
|
||||
}
|
||||
if ($headers !== self::HEADERS) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '表头不正确。请下载最新模板,并保持 11 个字段名称及顺序不变。',
|
||||
]);
|
||||
}
|
||||
|
||||
$dataRows = [];
|
||||
foreach (array_slice($rows, 1, null, true) as $index => $row) {
|
||||
$row = array_slice(array_pad($row, count(self::HEADERS), null), 0, count(self::HEADERS));
|
||||
if (collect($row)->every(fn (mixed $value): bool => $this->text($value) === '')) {
|
||||
continue;
|
||||
}
|
||||
$dataRows[] = ['line' => $index + 1, 'values' => $row];
|
||||
if (count($dataRows) > self::MAX_ROWS) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '单次最多导入 ' . self::MAX_ROWS . ' 条非空数据,请拆分文件后重试。',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($dataRows === []) {
|
||||
throw ValidationException::withMessages(['import' => '表格中没有可预览的数据行。']);
|
||||
}
|
||||
|
||||
$previewRows = array_map(fn (array $row): array => $this->normalizeRow($row['values'], $row['line']), $dataRows);
|
||||
$signatures = [];
|
||||
foreach ($previewRows as $index => $row) {
|
||||
if ($row['errors'] === []) {
|
||||
$signatures[$this->quotes->importIdentity($row['data'])][] = $index;
|
||||
}
|
||||
}
|
||||
foreach ($signatures as $indexes) {
|
||||
if (count($indexes) < 2) {
|
||||
continue;
|
||||
}
|
||||
$lines = implode('、', array_map(fn (int $index): int => $previewRows[$index]['line'], $indexes));
|
||||
foreach ($indexes as $index) {
|
||||
$previewRows[$index]['errors'][] = '文件内规则重复(行号:' . $lines . ')。';
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($previewRows as &$row) {
|
||||
if ($row['errors'] !== []) {
|
||||
$row['result'] = 'invalid';
|
||||
$row['result_label'] = '校验失败';
|
||||
continue;
|
||||
}
|
||||
$existing = $this->quotes->findImportDuplicate($row['data']);
|
||||
$row['existing_id'] = $existing?->id;
|
||||
$row['result'] = $existing ? 'duplicate' : 'new';
|
||||
$row['result_label'] = $existing ? '已有规则' : '可新增';
|
||||
}
|
||||
unset($row);
|
||||
|
||||
$collection = collect($previewRows);
|
||||
|
||||
return [
|
||||
'rows' => $previewRows,
|
||||
'summary' => [
|
||||
'total' => $collection->count(),
|
||||
'valid' => $collection->where('result', '!=', 'invalid')->count(),
|
||||
'new' => $collection->where('result', 'new')->count(),
|
||||
'duplicate' => $collection->where('result', 'duplicate')->count(),
|
||||
'invalid' => $collection->where('result', 'invalid')->count(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{line:int,data:array<string,mixed>,errors:list<string>}> $rows
|
||||
* @return array{created:int,updated:int,skipped:int,total:int}
|
||||
*/
|
||||
public function commit(array $rows, string $duplicatePolicy, int $userId): array
|
||||
{
|
||||
if (! in_array($duplicatePolicy, [self::DUPLICATE_SKIP, self::DUPLICATE_UPDATE], true)) {
|
||||
throw ValidationException::withMessages(['duplicate_policy' => '请选择已有规则的处理方式。']);
|
||||
}
|
||||
if ($rows === [] || count($rows) > self::MAX_ROWS || collect($rows)->contains(fn (array $row): bool => ($row['errors'] ?? []) !== [])) {
|
||||
throw ValidationException::withMessages(['import' => '预览数据无效或包含错误,请重新上传文件。']);
|
||||
}
|
||||
|
||||
$activeTypes = $this->quotes->activeTypeNames();
|
||||
foreach ($rows as $row) {
|
||||
$inactive = array_values(array_diff((array) ($row['data']['types'] ?? []), $activeTypes));
|
||||
if ($inactive !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '附加费类型“' . implode('、', $inactive) . '”已停用或不存在,请重新预览。',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$counts = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'total' => count($rows)];
|
||||
DB::transaction(function () use ($rows, $duplicatePolicy, $userId, &$counts): void {
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['data'];
|
||||
$status = $data['status'];
|
||||
unset($data['status']);
|
||||
|
||||
$existing = $this->quotes->findImportDuplicate($data, true);
|
||||
if ($existing && $duplicatePolicy === self::DUPLICATE_SKIP) {
|
||||
$counts['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$quote = $this->quotes->update($existing, $data, $userId);
|
||||
$counts['updated']++;
|
||||
} else {
|
||||
$quote = $this->quotes->create($data, $userId);
|
||||
$counts['created']++;
|
||||
}
|
||||
|
||||
if ($quote->status !== $status) {
|
||||
$this->quotes->setStatus($quote, $status, $userId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/** @return array{line:int,data:array<string,mixed>,display:array<string,string>,errors:list<string>,result:string,result_label:string,existing_id:?int} */
|
||||
private function normalizeRow(array $values, int $line): array
|
||||
{
|
||||
$raw = array_combine(self::HEADERS, $values);
|
||||
$display = [];
|
||||
foreach ($raw as $header => $value) {
|
||||
$display[$header] = $this->text($value);
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
foreach (['快递名称', '快递归属', '对账人', '附加费类型', '结算方式', '计价标准', '生效条件', '生效日期', '状态'] as $header) {
|
||||
if ($display[$header] === '') {
|
||||
$errors[] = $header . '不能为空。';
|
||||
}
|
||||
}
|
||||
|
||||
$this->checkLength($display, '快递名称', 191, $errors);
|
||||
$this->checkLength($display, '对账人', 100, $errors);
|
||||
$this->checkLength($display, '供应商', 191, $errors);
|
||||
$this->checkLength($display, '计价标准', 2000, $errors);
|
||||
$this->checkLength($display, '生效条件', 2000, $errors);
|
||||
$this->checkLength($display, '备注', 2000, $errors);
|
||||
|
||||
$types = array_values(array_unique(array_filter(array_map(
|
||||
'trim',
|
||||
preg_split('/[、,,;;]+/u', $display['附加费类型']) ?: [],
|
||||
))));
|
||||
if (count($types) > 50) {
|
||||
$errors[] = '附加费类型最多 50 项。';
|
||||
}
|
||||
$validTypes = $this->quotes->validTypeNames();
|
||||
$activeTypes = $this->quotes->activeTypeNames();
|
||||
$unknownTypes = array_values(array_diff($types, $validTypes));
|
||||
$inactiveTypes = array_values(array_diff(array_intersect($types, $validTypes), $activeTypes));
|
||||
if ($unknownTypes !== []) {
|
||||
$errors[] = '附加费类型不存在:' . implode('、', $unknownTypes) . '。';
|
||||
}
|
||||
if ($inactiveTypes !== []) {
|
||||
$errors[] = '附加费类型已停用:' . implode('、', $inactiveTypes) . '。';
|
||||
}
|
||||
if ($display['快递归属'] !== '' && ! in_array($display['快递归属'], CourierSurchargeQuoteRepository::SCOPES, true)) {
|
||||
$errors[] = '快递归属只能填写“自有快递”或“代发仓”。';
|
||||
}
|
||||
if ($display['结算方式'] !== '' && ! in_array($display['结算方式'], CourierSurchargeQuoteRepository::MODES, true)) {
|
||||
$errors[] = '结算方式不在允许范围内。';
|
||||
}
|
||||
|
||||
$effectiveAt = $this->date($raw['生效日期']);
|
||||
if ($display['生效日期'] !== '' && $effectiveAt === null) {
|
||||
$errors[] = '生效日期必须是有效的 YYYY-MM-DD 日期。';
|
||||
}
|
||||
|
||||
$status = match (mb_strtolower($display['状态'])) {
|
||||
'已启用', CourierSurchargeQuote::STATUS_ACTIVE => CourierSurchargeQuote::STATUS_ACTIVE,
|
||||
'已停用', CourierSurchargeQuote::STATUS_INACTIVE => CourierSurchargeQuote::STATUS_INACTIVE,
|
||||
default => null,
|
||||
};
|
||||
if ($display['状态'] !== '' && $status === null) {
|
||||
$errors[] = '状态只能填写“已启用/active”或“已停用/inactive”。';
|
||||
}
|
||||
|
||||
if ($effectiveAt !== null) {
|
||||
$display['生效日期'] = $effectiveAt;
|
||||
}
|
||||
|
||||
return [
|
||||
'line' => $line,
|
||||
'data' => [
|
||||
'courier' => $display['快递名称'],
|
||||
'scope' => $display['快递归属'],
|
||||
'reconciler' => $display['对账人'],
|
||||
'supplier' => $display['供应商'] !== '' ? $display['供应商'] : null,
|
||||
'types' => $types,
|
||||
'mode' => $display['结算方式'],
|
||||
'standard' => $display['计价标准'],
|
||||
'condition' => $display['生效条件'],
|
||||
'effective_at' => $effectiveAt,
|
||||
'status' => $status,
|
||||
'remark' => $display['备注'],
|
||||
],
|
||||
'display' => $display,
|
||||
'errors' => array_values(array_unique($errors)),
|
||||
'result' => 'invalid',
|
||||
'result_label' => '校验失败',
|
||||
'existing_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,string> $values @param list<string> $errors */
|
||||
private function checkLength(array $values, string $field, int $max, array &$errors): void
|
||||
{
|
||||
if (mb_strlen($values[$field]) > $max) {
|
||||
$errors[] = $field . '不能超过 ' . $max . ' 个字符。';
|
||||
}
|
||||
}
|
||||
|
||||
private function date(mixed $value): ?string
|
||||
{
|
||||
if (is_numeric($value) && (float) $value > 0) {
|
||||
try {
|
||||
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d');
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$value = $this->text($value);
|
||||
if (! preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1]) ? $value : null;
|
||||
}
|
||||
|
||||
private function text(mixed $value): string
|
||||
{
|
||||
return trim(str_replace("\xEF\xBB\xBF", '', (string) ($value ?? '')));
|
||||
}
|
||||
}
|
||||
@ -2,10 +2,12 @@
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\CourierSurchargeAuditLog;
|
||||
use App\Models\Business\CourierSurchargeQuote;
|
||||
use App\Models\Business\CourierSurchargeType;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@ -17,6 +19,10 @@ use Illuminate\Validation\ValidationException;
|
||||
*/
|
||||
class CourierSurchargeTypeRepository
|
||||
{
|
||||
public function __construct(private readonly CourierSurchargeAuditLogRepository $audits)
|
||||
{
|
||||
}
|
||||
|
||||
/** @var list<string> */
|
||||
public const DEFAULT_NAMES = [
|
||||
'退回费', '拦截费', '改地址', '异形件', '超长费',
|
||||
@ -89,14 +95,27 @@ class CourierSurchargeTypeRepository
|
||||
|
||||
$sortOrder = ((int) CourierSurchargeType::query()->max('sort_order')) + 1;
|
||||
|
||||
return CourierSurchargeType::query()->create([
|
||||
'company_id' => company_id(),
|
||||
'name' => $name,
|
||||
'status' => CourierSurchargeType::STATUS_ACTIVE,
|
||||
'sort_order' => $sortOrder,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
return DB::transaction(function () use ($name, $sortOrder, $userId): CourierSurchargeType {
|
||||
$type = CourierSurchargeType::query()->create([
|
||||
'company_id' => company_id(),
|
||||
'name' => $name,
|
||||
'status' => CourierSurchargeType::STATUS_ACTIVE,
|
||||
'sort_order' => $sortOrder,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
$this->audits->record(
|
||||
CourierSurchargeAuditLog::ACTION_CREATED,
|
||||
CourierSurchargeAuditLog::TARGET_TYPE,
|
||||
(int) $type->id,
|
||||
$type->name,
|
||||
['after' => ['name' => $type->name, 'status' => '已启用']],
|
||||
$userId,
|
||||
);
|
||||
|
||||
return $type;
|
||||
});
|
||||
} catch (QueryException $exception) {
|
||||
if ((int) $exception->getCode() === 23000) {
|
||||
throw ValidationException::withMessages(['name' => '当前公司已有同名附加费类型。']);
|
||||
@ -110,12 +129,37 @@ class CourierSurchargeTypeRepository
|
||||
{
|
||||
abort_unless(in_array($status, [CourierSurchargeType::STATUS_ACTIVE, CourierSurchargeType::STATUS_INACTIVE], true), 422, '附加费类型状态不合法。');
|
||||
|
||||
$type->forceFill([
|
||||
'status' => $status,
|
||||
'updated_by' => $userId,
|
||||
])->save();
|
||||
return DB::transaction(function () use ($type, $status, $userId): CourierSurchargeType {
|
||||
$oldStatus = $type->status;
|
||||
$type->forceFill([
|
||||
'status' => $status,
|
||||
'updated_by' => $userId,
|
||||
])->save();
|
||||
$fresh = $type->fresh();
|
||||
|
||||
return $type->fresh();
|
||||
if ($oldStatus !== $status) {
|
||||
$this->audits->record(
|
||||
$status === CourierSurchargeType::STATUS_ACTIVE
|
||||
? CourierSurchargeAuditLog::ACTION_ACTIVATED
|
||||
: CourierSurchargeAuditLog::ACTION_DEACTIVATED,
|
||||
CourierSurchargeAuditLog::TARGET_TYPE,
|
||||
(int) $fresh->id,
|
||||
$fresh->name,
|
||||
[
|
||||
'before' => ['status' => $this->statusLabel($oldStatus)],
|
||||
'after' => ['status' => $this->statusLabel($status)],
|
||||
],
|
||||
$userId,
|
||||
);
|
||||
}
|
||||
|
||||
return $fresh;
|
||||
});
|
||||
}
|
||||
|
||||
private function statusLabel(?string $status): string
|
||||
{
|
||||
return $status === CourierSurchargeType::STATUS_ACTIVE ? '已启用' : '已停用';
|
||||
}
|
||||
|
||||
private function ensureDefaults(int $companyId): void
|
||||
|
||||
213
app/Business/InventoryWarehouseNameMappingWorkbookImporter.php
Normal file
213
app/Business/InventoryWarehouseNameMappingWorkbookImporter.php
Normal file
@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
class InventoryWarehouseNameMappingWorkbookImporter
|
||||
{
|
||||
private const HEADERS = ['仓储方', '仓库', '仓库简称'];
|
||||
|
||||
/** @return array{rows:list<array>,sheet:string,headers:list<string>,summary:array} */
|
||||
public function preview(UploadedFile $file): array
|
||||
{
|
||||
$spreadsheet = IOFactory::load($file->getRealPath());
|
||||
$worksheet = $this->findWorksheet($spreadsheet);
|
||||
|
||||
if ($worksheet === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '未找到包含“仓储方、仓库、仓库简称”三列的工作表。Sheet2 仅是辅助仓库清单,不能直接导入。',
|
||||
]);
|
||||
}
|
||||
|
||||
$rows = $worksheet->toArray(null, true, false, false);
|
||||
$parsed = [];
|
||||
$seen = [];
|
||||
$created = 0;
|
||||
$same = 0;
|
||||
$conflicts = 0;
|
||||
$invalid = 0;
|
||||
|
||||
foreach (array_slice($rows, 1) as $offset => $row) {
|
||||
$rowNumber = $offset + 2;
|
||||
$provider = trim((string) ($row[0] ?? ''));
|
||||
$warehouse = trim((string) ($row[1] ?? ''));
|
||||
$shortName = trim((string) ($row[2] ?? ''));
|
||||
if ($provider === '' && $warehouse === '' && $shortName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $this->key($provider, $warehouse);
|
||||
$item = [
|
||||
'row_number' => $rowNumber,
|
||||
'warehouse_provider' => $provider,
|
||||
'warehouse' => $warehouse,
|
||||
'warehouse_short_name' => $shortName,
|
||||
'status' => 'new',
|
||||
'message' => '待新增',
|
||||
];
|
||||
|
||||
if ($provider === '' || $warehouse === '' || $shortName === '') {
|
||||
$item['status'] = 'invalid';
|
||||
$item['message'] = '仓储方、仓库、仓库简称均为必填字段';
|
||||
$invalid++;
|
||||
} elseif (isset($seen[$key])) {
|
||||
$item['status'] = $seen[$key]['warehouse_short_name'] === $shortName ? 'same' : 'conflict';
|
||||
$item['message'] = $item['status'] === 'same'
|
||||
? '与文件内前一行重复,将跳过'
|
||||
: '与文件内前一行的仓库简称冲突';
|
||||
$item['status'] === 'same' ? $same++ : $conflicts++;
|
||||
} else {
|
||||
$existing = InventoryWarehouseNameMapping::query()
|
||||
->where('warehouse_provider', $provider)
|
||||
->where('warehouse', $warehouse)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$item['status'] = $existing->warehouse_short_name === $shortName ? 'same' : 'conflict';
|
||||
$item['message'] = $item['status'] === 'same'
|
||||
? '数据库已有相同映射,将跳过'
|
||||
: '数据库已有不同简称,禁止静默覆盖';
|
||||
$item['status'] === 'same' ? $same++ : $conflicts++;
|
||||
} else {
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
$parsed[] = $item;
|
||||
if ($provider !== '' && $warehouse !== '' && $shortName !== '' && ! isset($seen[$key])) {
|
||||
$seen[$key] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'rows' => $parsed,
|
||||
'sheet' => $worksheet->getTitle(),
|
||||
'headers' => self::HEADERS,
|
||||
'summary' => compact('created', 'same', 'conflicts', 'invalid'),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array{rows:list<array>} $preview */
|
||||
public function commit(array $preview): array
|
||||
{
|
||||
$rows = $preview['rows'] ?? [];
|
||||
$invalid = collect($rows)->first(fn (array $row): bool => in_array($row['status'] ?? '', ['invalid', 'conflict'], true));
|
||||
if ($invalid !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '导入预览存在必填字段错误或简称冲突,请先处理标记为失败的行。',
|
||||
]);
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
DB::transaction(function () use ($rows, &$created, &$skipped): void {
|
||||
foreach ($rows as $row) {
|
||||
if (($row['status'] ?? '') === 'same') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$query = InventoryWarehouseNameMapping::query()
|
||||
->where('warehouse_provider', $row['warehouse_provider'])
|
||||
->where('warehouse', $row['warehouse']);
|
||||
if ($query->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => "第 {$row['row_number']} 行在确认时已被其他操作占用,请重新预览。",
|
||||
]);
|
||||
}
|
||||
|
||||
InventoryWarehouseNameMapping::create([
|
||||
'company_id' => company_id() ?: 1,
|
||||
'warehouse_provider' => $row['warehouse_provider'],
|
||||
'warehouse' => $row['warehouse'],
|
||||
'warehouse_short_name' => $row['warehouse_short_name'],
|
||||
'created_by' => auth()->id(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
$created++;
|
||||
}
|
||||
});
|
||||
|
||||
return compact('created', 'skipped');
|
||||
}
|
||||
|
||||
public static function suggestion(string $provider, string $warehouse): string
|
||||
{
|
||||
$provider = trim($provider);
|
||||
$warehouse = trim($warehouse);
|
||||
$haystack = $provider . ' ' . $warehouse;
|
||||
|
||||
if (str_starts_with($provider, '奇门-')) {
|
||||
return '不看';
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'配件单仓' => '配件单仓-不看',
|
||||
'POP仓' => '京东入仓-不看',
|
||||
'自动发货仓' => '虚拟仓-不看',
|
||||
'京东云仓' => '京东云仓-不看',
|
||||
'京东京造' => '京东京造-不看',
|
||||
'苏州福利品仓' => '苏州福利品仓-不看',
|
||||
'信誉订单仓' => '信誉订单仓-不看',
|
||||
'代发仓' => '代发仓',
|
||||
'销退仓' => '销退仓-不看',
|
||||
'客退' => '客退仓-不看',
|
||||
'次品仓' => '退货报废-不看',
|
||||
'数量校验仓' => '数量校验仓-不看',
|
||||
] as $needle => $result) {
|
||||
if (str_contains($haystack, $needle)) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'运营C崔桥工厂仓' => '崔桥工厂仓',
|
||||
'运营C横林二仓' => '横林二仓',
|
||||
'运营C睢宁' => '睢宁仓',
|
||||
'运营C新苏北' => '新苏北仓',
|
||||
'江西仓送货' => '南昌樵舍仓',
|
||||
] as $needle => $result) {
|
||||
if (str_contains($haystack, $needle)) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
$short = preg_replace('/(主仓|进货仓)$/u', '', $warehouse) ?: $warehouse;
|
||||
$short = preg_replace('/^(简尚-|京仓-|三方-|奇门-)/u', '', $short) ?: $short;
|
||||
$providerBase = preg_replace('/^(简尚-|京仓-|三方-|奇门-)/u', '', $provider) ?: $provider;
|
||||
if ($short === $providerBase || str_starts_with($short, $providerBase)) {
|
||||
$short = $providerBase;
|
||||
}
|
||||
|
||||
return $short !== '' ? $short : $warehouse;
|
||||
}
|
||||
|
||||
private function findWorksheet($spreadsheet): ?object
|
||||
{
|
||||
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
|
||||
$row = $worksheet->toArray(null, true, false, false)[0] ?? [];
|
||||
$headers = array_map(fn ($value): string => $this->normalize((string) $value), $row);
|
||||
$expected = array_map(fn ($value): string => $this->normalize($value), self::HEADERS);
|
||||
if (count(array_intersect($expected, $headers)) === count($expected)) {
|
||||
return $worksheet;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalize(string $value): string
|
||||
{
|
||||
return mb_strtolower(preg_replace('/[\s\x{FEFF}_\-()()]+/u', '', trim($value)));
|
||||
}
|
||||
|
||||
private function key(string $provider, string $warehouse): string
|
||||
{
|
||||
return $provider . "\0" . $warehouse;
|
||||
}
|
||||
}
|
||||
35
app/Business/InventoryWarehouseShortNames.php
Normal file
35
app/Business/InventoryWarehouseShortNames.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||||
|
||||
class InventoryWarehouseShortNames
|
||||
{
|
||||
/** @return array<string,string> */
|
||||
public function forCompany(int $companyId): array
|
||||
{
|
||||
return InventoryWarehouseNameMapping::allCompanies()
|
||||
->companyId($companyId)
|
||||
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name'])
|
||||
->mapWithKeys(static fn (InventoryWarehouseNameMapping $mapping): array => [
|
||||
self::key($mapping->warehouse_provider, $mapping->warehouse) => $mapping->warehouse_short_name,
|
||||
])->all();
|
||||
}
|
||||
|
||||
/** @param array<string,string> $names */
|
||||
public function fill(object $row, array $names): object
|
||||
{
|
||||
$row->warehouse_short_name = $names[self::key(
|
||||
(string) ($row->warehouse_provider ?? ''),
|
||||
(string) ($row->warehouse ?? ''),
|
||||
)] ?? '';
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function key(string $provider, string $warehouse): string
|
||||
{
|
||||
return $provider . "\0" . $warehouse;
|
||||
}
|
||||
}
|
||||
158
app/Business/JstOrderLabelImporter.php
Normal file
158
app/Business/JstOrderLabelImporter.php
Normal file
@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 聚水潭订单标签明细导入(从共享镜像 ops_jst_orders 拆标签写入 jst_order_labels)。
|
||||
*
|
||||
* 一个标签一行:一单有 N 个标签就写 N 行;订单不去重、标签不汇总。
|
||||
* 无标签的订单保留一行,标签记为「(无标签)」,方便与订单总数核对。
|
||||
*/
|
||||
class JstOrderLabelImporter
|
||||
{
|
||||
public const NO_LABEL = '(无标签)';
|
||||
|
||||
private const READ_CHUNK = 2000;
|
||||
|
||||
private const INSERT_CHUNK = 2000;
|
||||
|
||||
public function __construct(private readonly DatabaseManager $databases) {}
|
||||
|
||||
/**
|
||||
* @return array{from:string,to:string,orders:int,rows:int,no_label_orders:int,labels:int,seconds:float}
|
||||
*/
|
||||
public function import(string $from, string $to, int $companyId = 1, bool $replace = false, string $source = 'jst-order-label-import'): array
|
||||
{
|
||||
$from = $this->normalizeDate($from, '起始日期');
|
||||
$to = $this->normalizeDate($to, '结束日期');
|
||||
if ($from > $to) {
|
||||
throw new InvalidArgumentException('起始日期不能晚于结束日期。');
|
||||
}
|
||||
|
||||
$start = $from . ' 00:00:00';
|
||||
$end = date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00';
|
||||
|
||||
$mirror = $this->databases->connection(config('upstream_mirrors.connection', 'business_mirror'));
|
||||
if (! $mirror->getSchemaBuilder()->hasTable('ops_jst_orders')) {
|
||||
throw new RuntimeException('聚水潭订单镜像尚未建立(ops_jst_orders)。');
|
||||
}
|
||||
|
||||
$target = $this->databases->connection();
|
||||
$startedAt = microtime(true);
|
||||
|
||||
if ($replace) {
|
||||
$target->table('jst_order_labels')
|
||||
->where('company_id', $companyId)
|
||||
->where('ordered_at', '>=', $start)
|
||||
->where('ordered_at', '<', $end)
|
||||
->delete();
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$orders = 0;
|
||||
$rows = 0;
|
||||
$noLabelOrders = 0;
|
||||
$buffer = [];
|
||||
|
||||
$flush = function () use (&$buffer, &$rows, $target): void {
|
||||
foreach (array_chunk($buffer, self::INSERT_CHUNK) as $chunk) {
|
||||
$target->table('jst_order_labels')->insert($chunk);
|
||||
$rows += count($chunk);
|
||||
}
|
||||
$buffer = [];
|
||||
};
|
||||
|
||||
$mirror->table('ops_jst_orders')
|
||||
->where('order_date', '>=', $start)
|
||||
->where('order_date', '<', $end)
|
||||
->orderBy('Id')
|
||||
->chunkById(self::READ_CHUNK, function ($records) use (&$buffer, &$orders, &$noLabelOrders, $flush, $companyId, $source, $now): void {
|
||||
foreach ($records as $record) {
|
||||
$orders++;
|
||||
$orderedAt = $record->order_date === null ? null : substr((string) $record->order_date, 0, 19);
|
||||
if ($orderedAt === null || $orderedAt === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$labels = $this->splitLabels((string) ($record->labels ?? ''));
|
||||
if ($labels === []) {
|
||||
$labels = [self::NO_LABEL];
|
||||
$noLabelOrders++;
|
||||
}
|
||||
|
||||
foreach ($labels as $label) {
|
||||
$buffer[] = [
|
||||
'company_id' => $companyId,
|
||||
'order_no' => mb_substr((string) $record->so_id, 0, 100),
|
||||
'shop_name' => mb_substr((string) ($record->shop_name ?? ''), 0, 191),
|
||||
'ordered_at' => $orderedAt,
|
||||
'label' => $label,
|
||||
'created_from' => $source,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
if (count($buffer) >= self::INSERT_CHUNK * 2) {
|
||||
$flush();
|
||||
}
|
||||
}
|
||||
}, 'Id');
|
||||
|
||||
$flush();
|
||||
|
||||
$labels = $target->table('jst_order_labels')
|
||||
->where('company_id', $companyId)
|
||||
->where('ordered_at', '>=', $start)
|
||||
->where('ordered_at', '<', $end)
|
||||
->distinct()
|
||||
->count('label');
|
||||
|
||||
// 导入会改变筛选选项,重新预热缓存,避免页面下次打开时做几秒的 distinct 扫描。
|
||||
app(JstOrderLabelOptions::class)->forget($companyId);
|
||||
app(JstOrderLabelOptions::class)->warm($companyId);
|
||||
|
||||
return [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'orders' => $orders,
|
||||
'rows' => $rows,
|
||||
'no_label_orders' => $noLabelOrders,
|
||||
'labels' => (int) $labels,
|
||||
'seconds' => round(microtime(true) - $startedAt, 1),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function splitLabels(string $labels): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (explode(',', $labels) as $label) {
|
||||
$label = trim($label);
|
||||
if ($label !== '') {
|
||||
$out[] = mb_substr($label, 0, 191);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
private function normalizeDate(string $value, string $label): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
||||
throw new InvalidArgumentException($label . '必须是 YYYY-MM-DD。');
|
||||
}
|
||||
|
||||
[$year, $month, $day] = array_map('intval', explode('-', $value));
|
||||
if (! checkdate($month, $day, $year)) {
|
||||
throw new InvalidArgumentException($label . '不是有效日期。');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
96
app/Business/JstOrderLabelOptions.php
Normal file
96
app/Business/JstOrderLabelOptions.php
Normal file
@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\JstOrderLabel;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* 聚水潭订单标签明细的筛选下拉选项。
|
||||
*
|
||||
* 表里有 200 万行以上,distinct 扫描一次要几秒;这里按公司缓存 24 小时,
|
||||
* 导入命令结束时会主动预热,避免每次打开页面都慢。
|
||||
*/
|
||||
class JstOrderLabelOptions
|
||||
{
|
||||
private const TTL_HOURS = 24;
|
||||
|
||||
private const TOTAL_TTL_MINUTES = 30;
|
||||
|
||||
private const LIMIT = 500;
|
||||
|
||||
/** @return list<string> */
|
||||
public function labels(int $companyId): array
|
||||
{
|
||||
return $this->remember($companyId, 'label');
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function shops(int $companyId): array
|
||||
{
|
||||
return $this->remember($companyId, 'shop_name');
|
||||
}
|
||||
|
||||
public function warm(int $companyId): void
|
||||
{
|
||||
$this->labels($companyId);
|
||||
$this->shops($companyId);
|
||||
$this->total($companyId);
|
||||
}
|
||||
|
||||
public function forget(int $companyId): void
|
||||
{
|
||||
Cache::forget($this->key($companyId, 'label'));
|
||||
Cache::forget($this->key($companyId, 'shop_name'));
|
||||
Cache::forget($this->totalKey($companyId));
|
||||
}
|
||||
|
||||
/** 无筛选时的总行数:233 万行上 COUNT(*) 要 2 秒,缓存后翻页不再重复统计。 */
|
||||
public function total(int $companyId): int
|
||||
{
|
||||
$key = $this->totalKey($companyId);
|
||||
$cached = Cache::get($key);
|
||||
if (is_numeric($cached)) {
|
||||
return (int) $cached;
|
||||
}
|
||||
|
||||
$count = (int) JstOrderLabel::query()->where('company_id', $companyId)->count();
|
||||
Cache::put($key, $count, now()->addMinutes(self::TOTAL_TTL_MINUTES));
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function remember(int $companyId, string $column): array
|
||||
{
|
||||
$key = $this->key($companyId, $column);
|
||||
$cached = Cache::get($key);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$values = JstOrderLabel::query()
|
||||
->where('company_id', $companyId)
|
||||
->whereNotNull($column)
|
||||
->where($column, '<>', '')
|
||||
->distinct()
|
||||
->orderBy($column)
|
||||
->limit(self::LIMIT)
|
||||
->pluck($column)
|
||||
->all();
|
||||
|
||||
Cache::put($key, $values, now()->addHours(self::TTL_HOURS));
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
private function key(int $companyId, string $column): string
|
||||
{
|
||||
return "jst-order-labels:options:v1:{$companyId}:{$column}";
|
||||
}
|
||||
|
||||
private function totalKey(int $companyId): string
|
||||
{
|
||||
return "jst-order-labels:total:v1:{$companyId}";
|
||||
}
|
||||
}
|
||||
299
app/Business/PlantingDetailImporter.php
Normal file
299
app/Business/PlantingDetailImporter.php
Normal file
@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\PlantingDetail;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 种菜明细表导入(xlsx / xls / csv)。
|
||||
*
|
||||
* 源表头与 planting_details 列一一对应;按表头名匹配,列顺序变化不影响。
|
||||
* 日期兼容 Excel 序列号与常见字符串;金额按 4 位小数保存。
|
||||
*/
|
||||
class PlantingDetailImporter
|
||||
{
|
||||
/** 源表头 → 数据库列 */
|
||||
private const COLUMNS = [
|
||||
'日期' => 'bill_date',
|
||||
'订单编号' => 'order_no',
|
||||
'财务售价' => 'finance_selling_price',
|
||||
'财务运费' => 'finance_freight',
|
||||
'财务佣金' => 'finance_commission',
|
||||
'京喜差价' => 'jingxi_price_diff',
|
||||
'扣款' => 'deduction',
|
||||
'财务空包费' => 'finance_empty_package_fee',
|
||||
'财务打款金额' => 'finance_payment_amount',
|
||||
'笔数' => 'pieces',
|
||||
'备注' => 'remark',
|
||||
'正确店铺' => 'store',
|
||||
'负责人' => 'owner',
|
||||
'账户' => 'account',
|
||||
'类型' => 'type',
|
||||
'创建人' => 'creator',
|
||||
'标签' => 'tag',
|
||||
'所属利润中心' => 'profit_center',
|
||||
'负责平台' => 'responsible_platform',
|
||||
'ID' => 'external_id',
|
||||
];
|
||||
|
||||
private const MONEY_COLUMNS = [
|
||||
'finance_selling_price', 'finance_freight', 'finance_commission',
|
||||
'jingxi_price_diff', 'deduction', 'finance_empty_package_fee', 'finance_payment_amount',
|
||||
];
|
||||
|
||||
private const CHUNK = 1000;
|
||||
|
||||
/**
|
||||
* @return array{rows:int,inserted:int,skipped:int,selling_price:string,payment_amount:string,date_min:?string,date_max:?string}
|
||||
*/
|
||||
public function import(string $path, int $companyId = 1, bool $replace = false, ?int $userId = null, string $source = 'planting-detail-import'): array
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
throw new InvalidArgumentException('找不到要导入的文件:' . $path);
|
||||
}
|
||||
|
||||
$grid = $this->read($path);
|
||||
if ($grid === []) {
|
||||
throw new InvalidArgumentException('文件里没有可读的数据行。');
|
||||
}
|
||||
|
||||
$headerIndex = $this->locateHeader($grid);
|
||||
if ($headerIndex === null) {
|
||||
throw new InvalidArgumentException('没找到表头:至少要有「日期」「订单编号」「财务打款金额」。');
|
||||
}
|
||||
|
||||
$map = $this->mapHeader($grid[$headerIndex]);
|
||||
if (! isset($map['bill_date'], $map['order_no'], $map['finance_payment_amount'])) {
|
||||
throw new InvalidArgumentException('表头缺少「日期」或「订单编号」或「财务打款金额」。');
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$rows = [];
|
||||
$skipped = 0;
|
||||
$sellingPrice = 0.0;
|
||||
$paymentAmount = 0.0;
|
||||
$dateMin = null;
|
||||
$dateMax = null;
|
||||
$inserted = 0;
|
||||
|
||||
$connection = DB::connection();
|
||||
if ($replace) {
|
||||
PlantingDetail::query()->where('company_id', $companyId)->forceDelete();
|
||||
}
|
||||
|
||||
$flush = function () use (&$rows, &$inserted, $connection): void {
|
||||
if ($rows !== []) {
|
||||
foreach (array_chunk($rows, self::CHUNK) as $chunk) {
|
||||
$connection->table('planting_details')->insert($chunk);
|
||||
$inserted += count($chunk);
|
||||
}
|
||||
$rows = [];
|
||||
}
|
||||
};
|
||||
|
||||
foreach (array_slice($grid, $headerIndex + 1) as $gridRow) {
|
||||
if (! $this->hasContent($gridRow)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = $this->toDate($this->cell($gridRow, $map, 'bill_date'));
|
||||
$orderNo = $this->toText($this->cell($gridRow, $map, 'order_no'));
|
||||
if ($date === null || $orderNo === null) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$record = [
|
||||
'company_id' => $companyId,
|
||||
'bill_date' => $date,
|
||||
'order_no' => mb_substr($orderNo, 0, 100),
|
||||
'pieces' => (int) ($this->toNumber($this->cell($gridRow, $map, 'pieces')) ?? 0),
|
||||
'remark' => $this->toText($this->cell($gridRow, $map, 'remark')),
|
||||
'store' => $this->toText($this->cell($gridRow, $map, 'store')),
|
||||
'owner' => $this->toText($this->cell($gridRow, $map, 'owner')),
|
||||
'account' => $this->toText($this->cell($gridRow, $map, 'account')),
|
||||
'type' => $this->toText($this->cell($gridRow, $map, 'type')),
|
||||
'creator' => $this->toText($this->cell($gridRow, $map, 'creator')),
|
||||
'tag' => $this->toText($this->cell($gridRow, $map, 'tag')),
|
||||
'profit_center' => $this->toText($this->cell($gridRow, $map, 'profit_center')),
|
||||
'responsible_platform' => $this->toText($this->cell($gridRow, $map, 'responsible_platform')),
|
||||
// 源表存在一个单元格放多个 ID(换行分隔)的情况,这里保留到 500 字符。
|
||||
'external_id' => $this->toText($this->cell($gridRow, $map, 'external_id'), 500),
|
||||
'created_from' => $source,
|
||||
'created_by' => $userId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
foreach (self::MONEY_COLUMNS as $column) {
|
||||
$value = $this->toNumber($this->cell($gridRow, $map, $column)) ?? 0.0;
|
||||
$record[$column] = number_format($value, 4, '.', '');
|
||||
}
|
||||
$sellingPrice += (float) $record['finance_selling_price'];
|
||||
$paymentAmount += (float) $record['finance_payment_amount'];
|
||||
|
||||
$dateMin = $dateMin === null || $date < $dateMin ? $date : $dateMin;
|
||||
$dateMax = $dateMax === null || $date > $dateMax ? $date : $dateMax;
|
||||
|
||||
$rows[] = $record;
|
||||
if (count($rows) >= self::CHUNK * 4) {
|
||||
$flush();
|
||||
}
|
||||
}
|
||||
|
||||
$flush();
|
||||
|
||||
return [
|
||||
'rows' => $inserted + $skipped,
|
||||
'inserted' => $inserted,
|
||||
'skipped' => $skipped,
|
||||
'selling_price' => number_format($sellingPrice, 4, '.', ''),
|
||||
'payment_amount' => number_format($paymentAmount, 4, '.', ''),
|
||||
'date_min' => $dateMin,
|
||||
'date_max' => $dateMax,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int,array<int,mixed>> */
|
||||
private function read(string $path): array
|
||||
{
|
||||
$extension = mb_strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
if ($extension === 'csv') {
|
||||
$handle = fopen($path, 'rb');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('无法读取文件。');
|
||||
}
|
||||
$grid = [];
|
||||
$first = true;
|
||||
while (($cells = fgetcsv($handle)) !== false) {
|
||||
if ($first && isset($cells[0])) {
|
||||
$cells[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $cells[0]);
|
||||
$first = false;
|
||||
}
|
||||
$grid[] = $cells;
|
||||
}
|
||||
fclose($handle);
|
||||
|
||||
return $grid;
|
||||
}
|
||||
|
||||
if (! in_array($extension, ['xlsx', 'xls'], true)) {
|
||||
throw new InvalidArgumentException('只支持 xlsx、xls、csv。');
|
||||
}
|
||||
|
||||
try {
|
||||
$reader = IOFactory::createReader($extension === 'xlsx' ? 'Xlsx' : 'Xls');
|
||||
$reader->setReadDataOnly(true);
|
||||
$spreadsheet = $reader->load($path);
|
||||
} catch (Throwable $exception) {
|
||||
throw new InvalidArgumentException('读不出这个表格:' . $exception->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
return $spreadsheet->getSheet(0)->toArray(null, true, false, false);
|
||||
} finally {
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<int,array<int,mixed>> $grid */
|
||||
private function locateHeader(array $grid): ?int
|
||||
{
|
||||
foreach ($grid as $index => $row) {
|
||||
$map = $this->mapHeader($row);
|
||||
if (isset($map['bill_date'], $map['order_no'], $map['finance_payment_amount'])) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array<int,mixed> $row @return array<string,int> */
|
||||
private function mapHeader(array $row): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($row as $index => $value) {
|
||||
$label = trim((string) (is_scalar($value) ? $value : ''));
|
||||
if ($label === '') {
|
||||
continue;
|
||||
}
|
||||
$column = self::COLUMNS[$label] ?? null;
|
||||
if ($column !== null && ! isset($map[$column])) {
|
||||
$map[$column] = (int) $index;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/** @param array<int,mixed> $row @param array<string,int> $map */
|
||||
private function cell(array $row, array $map, string $column): mixed
|
||||
{
|
||||
return isset($map[$column]) ? ($row[$map[$column]] ?? null) : null;
|
||||
}
|
||||
|
||||
private function hasContent(array $row): bool
|
||||
{
|
||||
foreach ($row as $value) {
|
||||
if ($value !== null && trim((string) (is_scalar($value) ? $value : '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function toDate(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_numeric($value) && (float) $value > 20000 && (float) $value < 60000) {
|
||||
try {
|
||||
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d');
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$text = trim((string) $value);
|
||||
$timestamp = strtotime($text);
|
||||
|
||||
return $timestamp === false ? null : date('Y-m-d', $timestamp);
|
||||
}
|
||||
|
||||
private function toText(mixed $value, int $limit = 191): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = trim((string) (is_scalar($value) ? $value : ''));
|
||||
|
||||
return $text === '' ? null : mb_substr($text, 0, $limit);
|
||||
}
|
||||
|
||||
private function toNumber(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
$text = str_replace([',', ' ', '¥', '¥'], '', (string) $value);
|
||||
|
||||
return is_numeric($text) ? (float) $text : null;
|
||||
}
|
||||
}
|
||||
148
app/Business/StoreViolationMapper.php
Normal file
148
app/Business/StoreViolationMapper.php
Normal file
@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use InvalidArgumentException;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
|
||||
/**
|
||||
* 店铺违规信息预警:上游抓取数据与导入文件到统一表头的映射。
|
||||
*/
|
||||
class StoreViolationMapper
|
||||
{
|
||||
public function platformForSourceValue(mixed $value): ?string
|
||||
{
|
||||
$normalized = mb_strtolower(trim((string) $value));
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ((array) config('store_violations.platforms', []) as $slug => $platform) {
|
||||
if ($normalized === $slug) {
|
||||
return $slug;
|
||||
}
|
||||
|
||||
foreach ($platform['source_values'] ?? [] as $sourceValue) {
|
||||
if ($normalized === mb_strtolower(trim((string) $sourceValue))) {
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function categoryForContent(?string $content): string
|
||||
{
|
||||
$content = trim((string) $content);
|
||||
if ($content === '') {
|
||||
return (string) config('store_violations.default_category', '其他');
|
||||
}
|
||||
|
||||
foreach ((array) config('store_violations.category_rules', []) as $category => $keywords) {
|
||||
foreach ((array) $keywords as $keyword) {
|
||||
if ($keyword !== '' && mb_stripos($content, (string) $keyword) !== false) {
|
||||
return (string) $category;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (string) config('store_violations.default_category', '其他');
|
||||
}
|
||||
|
||||
/**
|
||||
* 上游抓取行(rpt.crawl_shop_violation)→ 统一字段。
|
||||
*
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>|null 平台无法识别时返回 null
|
||||
*/
|
||||
public function mapCrawlRow(array $row): ?array
|
||||
{
|
||||
$platform = $this->platformForSourceValue($row['platform'] ?? null);
|
||||
if ($platform === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = trim((string) ($row['violation_content'] ?? ''));
|
||||
|
||||
return [
|
||||
'platform' => $platform,
|
||||
'shop_name' => mb_substr(trim((string) ($row['shop_name'] ?? '')), 0, 191),
|
||||
'violation_type' => $content === '' ? null : mb_substr($content, 0, 500),
|
||||
'violation_category' => $this->categoryForContent($content),
|
||||
'violation_no' => mb_substr(trim((string) ($row['violation_no'] ?? '')), 0, 100),
|
||||
'violation_date' => $this->normalizeDate($row['violation_date'] ?? null),
|
||||
'status' => $this->normalizeLabel($row['status'] ?? null, 64),
|
||||
'appeal_deadline' => $this->normalizeDate($row['deadline'] ?? null),
|
||||
'window_name' => $this->normalizeLabel($row['window_name'] ?? null, 191),
|
||||
'crawl_date' => $this->normalizeDate($row['crawl_date'] ?? null),
|
||||
'source' => 'crawl',
|
||||
];
|
||||
}
|
||||
|
||||
public function normalizeDate(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return $value->format('Y-m-d');
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
$number = (float) $value;
|
||||
if ($number >= 20000 && $number <= 80000) {
|
||||
return ExcelDate::excelToDateTimeObject($number)->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
$text = trim((string) $value);
|
||||
if ($text === '' || in_array($text, ['-', '—', '无'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = str_replace(['年', '月'], '-', str_replace('日', '', $text));
|
||||
$text = str_replace(['/', '.'], '-', $text);
|
||||
$text = trim(preg_replace('/\s+/', ' ', $text) ?? $text);
|
||||
|
||||
try {
|
||||
return (new DateTimeImmutable($text))->format('Y-m-d');
|
||||
} catch (\Throwable) {
|
||||
throw new InvalidArgumentException('无法识别的日期:' . $value);
|
||||
}
|
||||
}
|
||||
|
||||
public function normalizePoints(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
$text = trim((string) $value);
|
||||
if ($text === '' || in_array($text, ['-', '—', '无', '不扣分'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = preg_replace('/(分|点|个?扣|扣除)/u', '', $text) ?? $text;
|
||||
$text = trim($text);
|
||||
if ($text === '' || ! is_numeric($text)) {
|
||||
throw new InvalidArgumentException('无法识别的扣分:' . $value);
|
||||
}
|
||||
|
||||
return (float) $text;
|
||||
}
|
||||
|
||||
private function normalizeLabel(mixed $value, int $limit): ?string
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
|
||||
return $text === '' ? null : mb_substr($text, 0, $limit);
|
||||
}
|
||||
}
|
||||
479
app/Business/StoreViolationWorkbookImporter.php
Normal file
479
app/Business/StoreViolationWorkbookImporter.php
Normal file
@ -0,0 +1,479 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 店铺违规信息预警:导入文件按统一表头映射到 store_violation_records。
|
||||
*
|
||||
* preview() 只解析和校验,不写库;commit() 按 (platform, violation_no) 幂等写入。
|
||||
*/
|
||||
class StoreViolationWorkbookImporter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StoreViolationMapper $mapper,
|
||||
private readonly StoreViolationRecordRepository $repository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{platform:string,sheet:string,columns:list<array{key:string,label:string}>,unknown_headers:list<string>,rows:list<array>,summary:array,existing_checked:bool}
|
||||
*/
|
||||
public function preview(UploadedFile $file, string $platform): array
|
||||
{
|
||||
$platforms = (array) config('store_violations.platforms', []);
|
||||
if (! array_key_exists($platform, $platforms)) {
|
||||
throw ValidationException::withMessages(['import' => '未知平台,请刷新页面后重试。']);
|
||||
}
|
||||
|
||||
if (! $this->repository->available()) {
|
||||
throw ValidationException::withMessages(['import' => '导入功能尚未就绪:统一表尚未建立,请联系管理员完成迁移。']);
|
||||
}
|
||||
|
||||
try {
|
||||
$spreadsheet = IOFactory::load($file->getRealPath());
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
throw ValidationException::withMessages(['import' => '无法读取该文件,请上传 xlsx / xls / csv 格式的表格。']);
|
||||
}
|
||||
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
$matrix = $worksheet->toArray(null, true, true, false);
|
||||
|
||||
[$headerIndex, $columnMap, $unknownHeaders] = $this->detectHeaders($matrix);
|
||||
if ($headerIndex === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '未找到可识别的表头行,请使用统一表头或下载导入模板。',
|
||||
]);
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
foreach (['shop_name', 'violation_no'] as $required) {
|
||||
if (! in_array($required, $columnMap, true)) {
|
||||
$missing[] = $this->labelFor($required);
|
||||
}
|
||||
}
|
||||
if ($missing !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '缺少必填列:' . implode('、', $missing) . '。请使用统一表头或下载导入模板。',
|
||||
]);
|
||||
}
|
||||
|
||||
$maxRows = (int) config('store_violations.import.max_rows', 20000);
|
||||
$dataRows = array_slice($matrix, $headerIndex + 1);
|
||||
if (count($dataRows) > $maxRows) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '单次最多导入 ' . number_format($maxRows) . ' 行,请拆分文件后重试。',
|
||||
]);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$seen = [];
|
||||
$invalid = 0;
|
||||
|
||||
foreach ($dataRows as $offset => $row) {
|
||||
$rowNumber = $headerIndex + 2 + $offset;
|
||||
$raw = $this->rowToFields($row, $columnMap);
|
||||
if ($this->isEmptyRow($raw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = $this->normalizeRow($raw, $platform, $rowNumber, $columnMap);
|
||||
|
||||
if ($item['status'] === 'valid') {
|
||||
$key = $platform . '|' . $item['data']['violation_no'];
|
||||
if (isset($seen[$key])) {
|
||||
$item = $seen[$key]['data'] === $item['data']
|
||||
? $this->markRow($item, 'same', '与文件内前一行重复,将跳过')
|
||||
: $this->markRow($item, 'invalid', '与文件内前一行的数据冲突,请先合并');
|
||||
} else {
|
||||
$seen[$key] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
if ($item['status'] === 'invalid') {
|
||||
$invalid++;
|
||||
}
|
||||
|
||||
$rows[] = $item;
|
||||
}
|
||||
|
||||
$summary = ['new' => 0, 'update' => 0, 'same' => 0, 'invalid' => $invalid];
|
||||
$this->compareWithExisting($rows, $platform, $summary);
|
||||
|
||||
return [
|
||||
'platform' => $platform,
|
||||
'sheet' => $worksheet->getTitle(),
|
||||
'columns' => collect((array) config('store_violations.columns', []))
|
||||
->map(fn (array $column): array => ['key' => $column['key'], 'label' => $column['label']])
|
||||
->all(),
|
||||
'unknown_headers' => $unknownHeaders,
|
||||
'provided_columns' => array_values($columnMap),
|
||||
'rows' => $rows,
|
||||
'summary' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array{rows:list<array>} $preview */
|
||||
public function commit(array $preview): array
|
||||
{
|
||||
$platform = (string) ($preview['platform'] ?? '');
|
||||
$rows = $preview['rows'] ?? [];
|
||||
$provided = array_values(array_unique((array) ($preview['provided_columns'] ?? [])));
|
||||
$now = now();
|
||||
|
||||
$unifiedColumns = array_map(
|
||||
static fn (array $column): string => (string) $column['key'],
|
||||
(array) config('store_violations.columns', []),
|
||||
);
|
||||
$updateColumns = array_values(array_intersect($unifiedColumns, $provided));
|
||||
if (! in_array('violation_category', $updateColumns, true) && in_array('violation_type', $provided, true)) {
|
||||
$updateColumns[] = 'violation_category';
|
||||
}
|
||||
$updateColumns[] = 'source';
|
||||
$updateColumns[] = 'updated_at';
|
||||
|
||||
$payload = [];
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$status = $row['status'] ?? '';
|
||||
if (! in_array($status, ['new', 'update'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = $row['data'] ?? [];
|
||||
if (($data['platform'] ?? null) !== $platform || ($data['violation_no'] ?? '') === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (array_values(array_diff($updateColumns, ['source', 'updated_at'])) as $column) {
|
||||
if (! array_key_exists($column, $data)) {
|
||||
$data[$column] = null;
|
||||
}
|
||||
}
|
||||
|
||||
$payload[] = $data + [
|
||||
'source' => 'import',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$status === 'new' ? $created++ : $updated++;
|
||||
}
|
||||
|
||||
$affected = $this->repository->upsertRows($payload, $updateColumns);
|
||||
|
||||
return ['created' => $created, 'updated' => $updated, 'affected' => $affected];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<list<mixed>> $matrix
|
||||
* @return array{0:?int,1:list<string>,2:list<string>}
|
||||
*/
|
||||
private function detectHeaders(array $matrix): array
|
||||
{
|
||||
$bestIndex = null;
|
||||
$bestMap = [];
|
||||
$bestUnknown = [];
|
||||
$bestScore = 0;
|
||||
|
||||
foreach ($matrix as $index => $row) {
|
||||
$map = [];
|
||||
$unknown = [];
|
||||
foreach ($row as $cell) {
|
||||
$label = trim((string) $cell);
|
||||
if ($label === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $this->matchHeader($label);
|
||||
if ($key === null) {
|
||||
$unknown[] = $label;
|
||||
continue;
|
||||
}
|
||||
if (! in_array($key, $map, true)) {
|
||||
$map[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
if (count($map) > $bestScore) {
|
||||
$bestIndex = $index;
|
||||
$bestMap = $map;
|
||||
$bestUnknown = $unknown;
|
||||
$bestScore = count($map);
|
||||
}
|
||||
|
||||
// 命中 6 列以上视为表头,避免把数据行误判为表头
|
||||
if ($bestScore >= 6) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($bestIndex === null || $bestScore < 2) {
|
||||
return [null, [], []];
|
||||
}
|
||||
|
||||
return [$bestIndex, $bestMap, array_values(array_unique($bestUnknown))];
|
||||
}
|
||||
|
||||
private function matchHeader(string $label): ?string
|
||||
{
|
||||
$normalized = mb_strtolower(preg_replace('/[\s"* ]/u', '', $label) ?? $label);
|
||||
|
||||
foreach ((array) config('store_violations.header_aliases', []) as $key => $aliases) {
|
||||
foreach ((array) $aliases as $alias) {
|
||||
$alias = mb_strtolower(preg_replace('/[\s"* ]/u', '', (string) $alias) ?? (string) $alias);
|
||||
if ($alias !== '' && $normalized === $alias) {
|
||||
return (string) $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<mixed> $row
|
||||
* @param list<string> $columnMap
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function rowToFields(array $row, array $columnMap): array
|
||||
{
|
||||
$fields = [];
|
||||
foreach ($row as $index => $cell) {
|
||||
$key = $columnMap[$index] ?? null;
|
||||
if ($key !== null) {
|
||||
$fields[$key] = $cell;
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields */
|
||||
private function isEmptyRow(array $fields): bool
|
||||
{
|
||||
foreach ($fields as $value) {
|
||||
if (trim((string) $value) !== '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $raw
|
||||
* @return array{row_number:int,status:string,message:string,data:array<string,mixed>}
|
||||
*/
|
||||
private function normalizeRow(array $raw, string $platform, int $rowNumber, array $provided): array
|
||||
{
|
||||
$errors = [];
|
||||
$data = ['platform' => $platform];
|
||||
$has = static fn (string $key): bool => in_array($key, $provided, true);
|
||||
|
||||
if ($has('shop_name')) {
|
||||
$shopName = trim((string) ($raw['shop_name'] ?? ''));
|
||||
if ($shopName === '') {
|
||||
$errors[] = '店铺名不能为空';
|
||||
} elseif (mb_strlen($shopName) > 191) {
|
||||
$errors[] = '店铺名超过 191 字';
|
||||
}
|
||||
$data['shop_name'] = mb_substr($shopName, 0, 191);
|
||||
}
|
||||
|
||||
if ($has('violation_no')) {
|
||||
$violationNo = trim((string) ($raw['violation_no'] ?? ''));
|
||||
if ($violationNo === '') {
|
||||
$errors[] = '违规单号不能为空';
|
||||
} elseif (mb_strlen($violationNo) > 100) {
|
||||
$errors[] = '违规单号超过 100 字';
|
||||
}
|
||||
$data['violation_no'] = mb_substr($violationNo, 0, 100);
|
||||
}
|
||||
|
||||
$type = $has('violation_type') ? trim((string) ($raw['violation_type'] ?? '')) : null;
|
||||
if ($has('violation_type')) {
|
||||
$data['violation_type'] = $type === '' ? null : mb_substr($type, 0, 500);
|
||||
}
|
||||
|
||||
if ($has('violation_category')) {
|
||||
$category = trim((string) ($raw['violation_category'] ?? ''));
|
||||
$data['violation_category'] = $category === ''
|
||||
? $this->mapper->categoryForContent($type)
|
||||
: mb_substr($category, 0, 64);
|
||||
} elseif ($has('violation_type')) {
|
||||
$data['violation_category'] = $this->mapper->categoryForContent($type);
|
||||
}
|
||||
|
||||
if ($has('violation_date')) {
|
||||
try {
|
||||
$data['violation_date'] = $this->mapper->normalizeDate($raw['violation_date'] ?? null);
|
||||
} catch (Throwable $exception) {
|
||||
$errors[] = '违规日期:' . $exception->getMessage();
|
||||
$data['violation_date'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($has('points_deducted')) {
|
||||
try {
|
||||
$data['points_deducted'] = $this->mapper->normalizePoints($raw['points_deducted'] ?? null);
|
||||
} catch (Throwable $exception) {
|
||||
$errors[] = '扣分:' . $exception->getMessage();
|
||||
$data['points_deducted'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($has('status')) {
|
||||
$status = trim((string) ($raw['status'] ?? ''));
|
||||
$data['status'] = $status === '' ? null : mb_substr($status, 0, 64);
|
||||
}
|
||||
|
||||
if ($has('penalty_date')) {
|
||||
try {
|
||||
$data['penalty_date'] = $this->mapper->normalizeDate($raw['penalty_date'] ?? null);
|
||||
} catch (Throwable $exception) {
|
||||
$errors[] = '处罚日期:' . $exception->getMessage();
|
||||
$data['penalty_date'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($has('appeal_deadline')) {
|
||||
try {
|
||||
$data['appeal_deadline'] = $this->mapper->normalizeDate($raw['appeal_deadline'] ?? null);
|
||||
} catch (Throwable $exception) {
|
||||
$errors[] = '申诉截止日期:' . $exception->getMessage();
|
||||
$data['appeal_deadline'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($has('handling_status')) {
|
||||
$handling = trim((string) ($raw['handling_status'] ?? ''));
|
||||
$options = (array) config('store_violations.handling_statuses', []);
|
||||
if ($handling === '') {
|
||||
$data['handling_status'] = (string) config('store_violations.default_handling_status', '未处理');
|
||||
} elseif (! in_array($handling, $options, true)) {
|
||||
$errors[] = '处理状态必须是:' . implode('、', $options);
|
||||
$data['handling_status'] = $handling;
|
||||
} else {
|
||||
$data['handling_status'] = $handling;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'row_number' => $rowNumber,
|
||||
'status' => $errors === [] ? 'valid' : 'invalid',
|
||||
'message' => $errors === [] ? '待导入' : implode(';', $errors),
|
||||
'data' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
private function markRow(array $row, string $status, string $message): array
|
||||
{
|
||||
$row['status'] = $status;
|
||||
$row['message'] = $message;
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 与库内已有记录比对:无 → new,字段完全一致 → same,否则 → update。
|
||||
*
|
||||
* @param list<array> $rows
|
||||
*/
|
||||
private function compareWithExisting(array &$rows, string $platform, array &$summary): void
|
||||
{
|
||||
$keys = collect($rows)
|
||||
->filter(fn (array $row): bool => $row['status'] === 'valid' && ($row['data']['violation_no'] ?? '') !== '')
|
||||
->pluck('data.violation_no')
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($keys->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = collect();
|
||||
foreach ($keys->chunk(500) as $chunk) {
|
||||
$existing = $existing->merge(
|
||||
DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
|
||||
->table(StoreViolationRecordRepository::TABLE)
|
||||
->where('platform', $platform)
|
||||
->whereIn('violation_no', $chunk->all())
|
||||
->get()
|
||||
->keyBy('violation_no'),
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($rows as $index => $row) {
|
||||
if ($row['status'] !== 'valid') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$record = $existing->get($row['data']['violation_no']);
|
||||
if ($record === null) {
|
||||
$summary['new']++;
|
||||
$rows[$index] = $this->markRow($row, 'new', '新增记录');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[$index] = $this->sameAsExisting($row['data'], $record)
|
||||
? $this->markRow($row, 'same', '与库内记录一致,将跳过')
|
||||
: $this->markRow($row, 'update', '库内已有该单号,将按文件内容更新');
|
||||
$rows[$index]['status'] === 'same' ? $summary['same']++ : $summary['update']++;
|
||||
}
|
||||
}
|
||||
|
||||
private function sameAsExisting(array $data, object $record): bool
|
||||
{
|
||||
$normalizePoints = static function (mixed $value): string {
|
||||
if ($value === null || $value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return number_format((float) $value, 2, '.', '');
|
||||
};
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if ($key === 'platform') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$current = $record->{$key} ?? null;
|
||||
|
||||
if ($key === 'points_deducted') {
|
||||
if ($normalizePoints($value) !== $normalizePoints($current)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trim((string) $value) !== trim((string) $current)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function labelFor(string $key): string
|
||||
{
|
||||
foreach ((array) config('store_violations.columns', []) as $column) {
|
||||
if (($column['key'] ?? null) === $key) {
|
||||
return (string) $column['label'];
|
||||
}
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
@ -35,7 +35,7 @@ class TaobaoAlipayBillSpreadsheetImporter
|
||||
'counterparty_account', 'account_order_no', 'business_serial_no', 'goods_name',
|
||||
];
|
||||
|
||||
public function import(string $path, int $companyId): array
|
||||
public function import(string $path, int $companyId, bool $replace = false): array
|
||||
{
|
||||
if (! is_file($path) || ! is_readable($path)) {
|
||||
throw new InvalidArgumentException('导入文件不存在或不可读取。');
|
||||
@ -55,21 +55,27 @@ class TaobaoAlipayBillSpreadsheetImporter
|
||||
throw new InvalidArgumentException('表头与淘系支付宝账户模板不一致。');
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$skipped = 0;
|
||||
for ($rowNumber = 2; $rowNumber <= $sheet->getHighestDataRow(); $rowNumber++) {
|
||||
$values = $sheet->rangeToArray("A{$rowNumber}:Y{$rowNumber}", null, false, false)[0];
|
||||
if (collect($values)->every(fn ($value) => $value === null || trim((string) $value) === '')) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$rows[] = $this->normalizeRow(array_combine(self::FIELDS, $values), $rowNumber);
|
||||
}
|
||||
|
||||
$inserted = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
$replaced = 0;
|
||||
|
||||
DB::transaction(function () use ($sheet, $companyId, &$inserted, &$updated, &$skipped) {
|
||||
for ($rowNumber = 2; $rowNumber <= $sheet->getHighestDataRow(); $rowNumber++) {
|
||||
$values = $sheet->rangeToArray("A{$rowNumber}:Y{$rowNumber}", null, false, false)[0];
|
||||
DB::transaction(function () use ($rows, $companyId, $replace, &$inserted, &$updated, &$replaced) {
|
||||
if ($replace) {
|
||||
$replaced = DB::table('ec_alipay_bills')->where('company_id', $companyId)->delete();
|
||||
}
|
||||
|
||||
if (collect($values)->every(fn ($value) => $value === null || trim((string) $value) === '')) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = array_combine(self::FIELDS, $values);
|
||||
$data = $this->normalizeRow($data, $rowNumber);
|
||||
foreach ($rows as $data) {
|
||||
$record = AlipayBill::query()
|
||||
->where('company_id', $companyId)
|
||||
->where('record_code', $data['record_code'])
|
||||
@ -93,7 +99,7 @@ class TaobaoAlipayBillSpreadsheetImporter
|
||||
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
|
||||
return compact('inserted', 'updated', 'skipped');
|
||||
return compact('inserted', 'updated', 'skipped', 'replaced');
|
||||
}
|
||||
|
||||
private function normalizeRow(array $data, int $rowNumber): array
|
||||
|
||||
@ -37,7 +37,7 @@ class ThirdPartyWarehouseStorageFeeCalculator
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{summary:array<string,mixed>,daily:list<array<string,mixed>>,per_sku:list<array<string,mixed>>}
|
||||
* @return array{summary:array<string,mixed>,daily:list<array<string,mixed>>,per_sku:list<array<string,mixed>>,daily_sku:list<array<string,mixed>>}
|
||||
*/
|
||||
public function calculate(int $warehouseId, string $warehouseName, string $period): array
|
||||
{
|
||||
@ -63,6 +63,10 @@ class ThirdPartyWarehouseStorageFeeCalculator
|
||||
$daily = [];
|
||||
/** @var array<string,array<string,mixed>> $skus */
|
||||
$skus = [];
|
||||
$dailySku = [];
|
||||
$storagePerPiecePerDay = $days > 0 && $piecesPerPallet > 0
|
||||
? $palletArea * $sqmRate / $piecesPerPallet / $days
|
||||
: 0.0;
|
||||
|
||||
foreach ($this->mirror->exportRows(['dimension' => 'month', 'period' => $period], 5000) as $row) {
|
||||
$date = trim((string) ($row['date'] ?? ''));
|
||||
@ -90,6 +94,20 @@ class ThirdPartyWarehouseStorageFeeCalculator
|
||||
$daily[$date]['inbound_qty'] += $inboundQty;
|
||||
$daily[$date]['outbound_qty'] += $outboundQty;
|
||||
|
||||
$dailySku[] = [
|
||||
'date' => $date,
|
||||
'owner' => (string) ($row['owner'] ?? ''),
|
||||
'sku_code' => $code,
|
||||
'product_name' => (string) ($row['product_name'] ?? ''),
|
||||
'supplier' => (string) ($row['supplier'] ?? ''),
|
||||
'category' => (string) ($row['category'] ?? ''),
|
||||
'unit' => (string) ($row['unit'] ?? ''),
|
||||
'opening' => round($opening, 2),
|
||||
'net_movement' => round($closing - $opening, 2),
|
||||
'closing' => round($closing, 2),
|
||||
'storage_fee' => round($closing * $storagePerPiecePerDay, 6),
|
||||
];
|
||||
|
||||
if (! isset($skus[$code])) {
|
||||
$skus[$code] = [
|
||||
'owner' => (string) ($row['owner'] ?? ''),
|
||||
@ -198,6 +216,7 @@ class ThirdPartyWarehouseStorageFeeCalculator
|
||||
],
|
||||
'daily' => $dailyRows,
|
||||
'per_sku' => $perSku,
|
||||
'daily_sku' => $dailySku,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,10 +88,12 @@ class UpstreamBusinessInformation
|
||||
|
||||
private function companyPage(int $page, int $perPage): array
|
||||
{
|
||||
$filters = $this->pageFilters('company-management', 'basic-information');
|
||||
return $this->page(
|
||||
'company-information.read',
|
||||
$page,
|
||||
$perPage,
|
||||
$filters,
|
||||
fn () => $this->snapshot('company-information.read', [
|
||||
'page' => $page,
|
||||
'perPage' => $perPage,
|
||||
@ -133,10 +135,12 @@ SQL);
|
||||
|
||||
private function storePage(int $page, int $perPage): array
|
||||
{
|
||||
$filters = $this->pageFilters('store-management', 'basic-information');
|
||||
return $this->page(
|
||||
'store-information.read',
|
||||
$page,
|
||||
$perPage,
|
||||
$filters,
|
||||
fn () => $this->snapshot('store-information.read', [
|
||||
'page' => $page,
|
||||
'perPage' => $perPage,
|
||||
@ -157,7 +161,11 @@ SQL);
|
||||
private function storeAccountPage(int $page, int $perPage): array
|
||||
{
|
||||
try {
|
||||
return ($this->storeAccounts ?? app(StoreAccountMirrorRepository::class))->paginate($page, $perPage);
|
||||
return ($this->storeAccounts ?? app(StoreAccountMirrorRepository::class))->paginate(
|
||||
$page,
|
||||
$perPage,
|
||||
$this->pageFilters('store-management', 'store-accounts'),
|
||||
);
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
throw new ServiceUnavailableHttpException(null, '店铺账号共享镜像尚未完成首次同步。');
|
||||
@ -189,16 +197,20 @@ SQL);
|
||||
*/
|
||||
private function storeMatchesFilters(array $row, array $filters): bool
|
||||
{
|
||||
foreach (['erp_store_name', 'store_name', 'store_id', 'profit_center', 'store_manager', 'department_manager', 'company_type'] as $key) {
|
||||
foreach (['erp_store_name', 'store_name', 'store_id', 'profit_center', 'store_manager', 'department_manager'] as $key) {
|
||||
$expected = trim((string) ($filters[$key] ?? ''));
|
||||
if ($expected === '') {
|
||||
continue;
|
||||
}
|
||||
$actual = trim((string) ($row[$key] ?? ''));
|
||||
if ($actual !== $expected) {
|
||||
if (! str_contains(mb_strtolower($actual), mb_strtolower($expected))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$companyType = trim((string) ($filters['company_type'] ?? ''));
|
||||
if ($companyType !== '' && trim((string) ($row['company_type'] ?? '')) !== $companyType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$statusMap = ['0' => '正常', '3' => '关闭中', '6' => '已关闭'];
|
||||
$status = trim((string) ($filters['status'] ?? ''));
|
||||
@ -244,7 +256,8 @@ SQL);
|
||||
|
||||
private function subjectOperatingReportPage(int $page, int $perPage): array
|
||||
{
|
||||
$rows = $this->storeReportRows();
|
||||
$filters = $this->pageFilters('company-management', 'subject-operating-report');
|
||||
$rows = $this->storeReportRows(array_intersect_key($filters, array_flip(['company_type'])));
|
||||
$entities = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
@ -276,20 +289,108 @@ SQL);
|
||||
}
|
||||
|
||||
$rows = array_values($entities);
|
||||
usort($rows, static fn (array $left, array $right): int => strcmp($left['entity'], $right['entity']));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$pageRows = array_slice($rows, $offset, $perPage);
|
||||
$details = $this->companyDetails(array_column($pageRows, 'entity'));
|
||||
foreach ($pageRows as &$row) {
|
||||
$details = $this->companyDetails(array_column($rows, 'entity'));
|
||||
foreach ($rows as &$row) {
|
||||
$row['company_details'] = $details[$row['entity']] ?? [];
|
||||
$row['city'] = $this->cityFromDetails($row['company_details']);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
// 所有主体该月收入均为空时,该月份列在页面上默认隐藏;「筛选字段」中仍保留勾选项,可手动选出来。
|
||||
request()->attributes->set(
|
||||
'subject-operating-report.default_hidden_columns',
|
||||
$this->subjectReportColumnsWithoutData($rows),
|
||||
);
|
||||
|
||||
$rows = array_values(array_filter($rows, fn (array $row): bool => $this->subjectMatchesFilters($row, $filters)));
|
||||
$this->sortSubjectRows($rows, $filters);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$pageRows = array_slice($rows, $offset, $perPage);
|
||||
|
||||
return ['data' => $pageRows, 'total' => count($rows)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 没有数据的月份列:全部主体该月收入都为空(null/空字符串)时才视为没有数据。
|
||||
* 金额为 0 的月份代表当月有账单,不隐藏。
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private function subjectReportColumnsWithoutData(array $rows): array
|
||||
{
|
||||
$columns = [];
|
||||
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$key = sprintf('income_%02d', $month);
|
||||
foreach ($rows as $row) {
|
||||
$value = $row[$key] ?? null;
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
continue 2;
|
||||
}
|
||||
|
||||
$columns[] = $key;
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row @param array<string,string> $filters */
|
||||
private function subjectMatchesFilters(array $row, array $filters): bool
|
||||
{
|
||||
if (($filters['company_type'] ?? '') !== '' && (string) ($row['company_type'] ?? '') !== (string) $filters['company_type']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (['city', 'entity', 'plan', 'new_entity'] as $key) {
|
||||
$needle = trim((string) ($filters[$key] ?? ''));
|
||||
if ($needle !== '' && mb_stripos((string) ($row[$key] ?? ''), $needle) === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['rolling_12m_income', ...array_map(static fn (int $month): string => sprintf('income_%02d', $month), range(1, 12))] as $key) {
|
||||
$needle = str_replace(',', '', trim((string) ($filters[$key] ?? '')));
|
||||
if ($needle !== '' && (! is_numeric($needle) || ! is_numeric($row[$key] ?? null)
|
||||
|| abs((float) $row[$key] - (float) $needle) > 0.000001)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$canChange = trim((string) ($filters['can_change_entity'] ?? ''));
|
||||
return $canChange === '' || (string) ($row['can_change_entity'] ?? '') === $canChange;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @param array<string,string> $filters */
|
||||
private function sortSubjectRows(array &$rows, array $filters): void
|
||||
{
|
||||
$key = (string) ($filters['_sort'] ?? 'entity');
|
||||
$allowed = [
|
||||
'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',
|
||||
];
|
||||
if (! in_array($key, $allowed, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$direction = ($filters['_direction'] ?? 'asc') === 'desc' ? -1 : 1;
|
||||
usort($rows, static function (array $left, array $right) use ($key, $direction): int {
|
||||
$leftValue = $left[$key] ?? null;
|
||||
$rightValue = $right[$key] ?? null;
|
||||
if (is_numeric($leftValue) && is_numeric($rightValue)) {
|
||||
$comparison = ((float) $leftValue <=> (float) $rightValue) * $direction;
|
||||
} else {
|
||||
$comparison = strcmp((string) ($leftValue ?? ''), (string) ($rightValue ?? '')) * $direction;
|
||||
}
|
||||
|
||||
return $comparison !== 0 ? $comparison : strcmp((string) ($left['entity'] ?? ''), (string) ($right['entity'] ?? ''));
|
||||
});
|
||||
}
|
||||
|
||||
private function sumNullable(array &$row, string $key, mixed $value): void
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
@ -469,10 +570,12 @@ SQL);
|
||||
|
||||
private function trademarkPage(int $page, int $perPage): array
|
||||
{
|
||||
$filters = $this->pageFilters('trademark-management', 'information');
|
||||
return $this->page(
|
||||
'trademark-information.read',
|
||||
$page,
|
||||
$perPage,
|
||||
$filters,
|
||||
fn () => $this->snapshot('trademark-information.read', [
|
||||
'page' => $page,
|
||||
'perPage' => $perPage,
|
||||
@ -509,10 +612,14 @@ SQL);
|
||||
];
|
||||
}
|
||||
|
||||
private function page(string $scope, int $page, int $perPage, callable $resolver): array
|
||||
private function page(string $scope, int $page, int $perPage, array $filters, callable $resolver): array
|
||||
{
|
||||
if ($this->mirror->ready($scope)) {
|
||||
return $this->normalize($scope, $this->mirror->paginate($scope, $page, $perPage));
|
||||
return $this->normalize($scope, $this->mirror->paginate($scope, $page, $perPage, $filters));
|
||||
}
|
||||
|
||||
if ($filters !== []) {
|
||||
throw new ServiceUnavailableHttpException(null, '当前数据源暂不支持所选条件查询,请检查共享业务镜像状态。');
|
||||
}
|
||||
|
||||
if ((app(\App\Utilities\SystemDatabaseConfiguration::class)->current()['mode'] ?? '') === 'bootstrap'
|
||||
@ -527,6 +634,34 @@ SQL);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private function pageFilters(string $section, string $page): array
|
||||
{
|
||||
$fields = collect(config('business_navigation'))
|
||||
->firstWhere('slug', $section)['pages'] ?? [];
|
||||
$pageConfig = collect($fields)->flatMap(fn (array $item) => [$item, ...($item['children'] ?? [])])->firstWhere('slug', $page);
|
||||
$allowed = collect($pageConfig['directory']['fields'] ?? [])
|
||||
->reject(fn (array $field) => ($field['kind'] ?? '') === 'secret' && ($field['name'] ?? '') !== 'withdrawal_account')
|
||||
->pluck('name')->all();
|
||||
if ($section === 'company-management' && $page === 'basic-information') {
|
||||
$allowed = array_values(array_unique([...$allowed, 'company_type', 'related_people']));
|
||||
}
|
||||
$filters = [];
|
||||
foreach ($allowed as $name) {
|
||||
foreach ([$name, $name . '_from', $name . '_to'] as $key) {
|
||||
$value = trim((string) request($key, ''));
|
||||
if ($value !== '') $filters[$key] = mb_substr($value, 0, 191);
|
||||
}
|
||||
}
|
||||
$sort = trim((string) request('sort', ''));
|
||||
if (in_array($sort, $allowed, true)) {
|
||||
$filters['_sort'] = $sort;
|
||||
$filters['_direction'] = request('direction') === 'desc' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
return $filters;
|
||||
}
|
||||
|
||||
/** @param array{data: array<int,array<string,mixed>>, total: int} $payload */
|
||||
private function normalize(string $scope, array $payload): array
|
||||
{
|
||||
@ -548,6 +683,7 @@ SQL);
|
||||
$row = $this->toUtf8($row);
|
||||
$row['establishment_date'] = $this->date($row['establishment_date'] ?? null);
|
||||
$row['subscribed_at'] = $this->date($row['subscribed_at'] ?? null);
|
||||
$row['city'] = RegisteredAddressCity::parse($row['registered_address'] ?? null);
|
||||
$row['company_status'] = match ((string) ($row['company_status'] ?? '')) {
|
||||
'0' => '正常',
|
||||
'1' => '已注销',
|
||||
|
||||
277
app/Business/WithdrawalManagementReport.php
Normal file
277
app/Business/WithdrawalManagementReport.php
Normal file
@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\StoreReceivingAccount;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
|
||||
|
||||
class WithdrawalManagementReport
|
||||
{
|
||||
private const SORT_FIELDS = [
|
||||
'platform',
|
||||
'erp_store_name',
|
||||
'store_id',
|
||||
'store_entity',
|
||||
'withdrawal_path',
|
||||
'withdrawal_account',
|
||||
'bank_card',
|
||||
'balance',
|
||||
'reserve_standard',
|
||||
'withdrawal_amount',
|
||||
'compliance',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly JushuitanShopBalanceRepository $shopBalances,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* 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}>,
|
||||
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
|
||||
* 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
|
||||
{
|
||||
$filters = $this->filters($request);
|
||||
$sort = $this->sorting($request);
|
||||
$platforms = $this->options('store_platform');
|
||||
$paths = $this->options('receiving_method');
|
||||
$storeEntities = $this->options('store_entity_name');
|
||||
$query = StoreReceivingAccount::query();
|
||||
|
||||
if ($filters['platform'] !== '') {
|
||||
$query->where('store_platform', $filters['platform']);
|
||||
}
|
||||
if ($filters['withdrawalPath'] !== '') {
|
||||
$query->where('receiving_method', $filters['withdrawalPath']);
|
||||
}
|
||||
if ($filters['storeEntity'] !== '') {
|
||||
$query->where('store_entity_name', $filters['storeEntity']);
|
||||
}
|
||||
if ($filters['keyword'] !== '') {
|
||||
$query->where('store_name', 'like', '%' . addcslashes($filters['keyword'], '%_\\') . '%');
|
||||
}
|
||||
|
||||
$today = now()->startOfDay();
|
||||
$balanceDate = $today->copy()->subDay()->toDateString();
|
||||
$rows = $query->orderByDesc('id')->get();
|
||||
$balances = $this->shopBalances->matchesForStores($rows, $balanceDate);
|
||||
$balanceCounts = collect($balances)->countBy(
|
||||
static fn (array $match): string => $match['status'],
|
||||
);
|
||||
|
||||
if ($filters['balanceStatus'] !== '') {
|
||||
$rows = $rows->filter(
|
||||
static fn (StoreReceivingAccount $row): bool => ($balances[(int) $row->id]['status'] ?? 'not_ready') === $filters['balanceStatus'],
|
||||
);
|
||||
}
|
||||
|
||||
$reserveStandards = $rows->mapWithKeys(
|
||||
static fn (StoreReceivingAccount $row): array => [
|
||||
(int) $row->id => WithdrawalReserveStandard::amountFor(
|
||||
$row->store_platform,
|
||||
$row->erp_store_name,
|
||||
),
|
||||
],
|
||||
)->all();
|
||||
$withdrawalAmounts = $rows->mapWithKeys(
|
||||
fn (StoreReceivingAccount $row): array => [
|
||||
(int) $row->id => $this->withdrawalAmountFor($row, $balanceDate),
|
||||
],
|
||||
)->all();
|
||||
$compliance = $rows->mapWithKeys(
|
||||
fn (StoreReceivingAccount $row): array => [
|
||||
(int) $row->id => $this->complianceFor(
|
||||
$balances[(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null],
|
||||
$reserveStandards[(int) $row->id] ?? null,
|
||||
$withdrawalAmounts[(int) $row->id] ?? null,
|
||||
),
|
||||
],
|
||||
)->all();
|
||||
$rows = $sort['field'] === ''
|
||||
? $this->sortByBalanceStatus($rows, $balances)
|
||||
: $this->sortRows($rows, $sort, $balances, $reserveStandards, $withdrawalAmounts, $compliance);
|
||||
|
||||
return [
|
||||
'filters' => $filters,
|
||||
'platforms' => $platforms,
|
||||
'paths' => $paths,
|
||||
'storeEntities' => $storeEntities,
|
||||
'rows' => $rows,
|
||||
'balances' => $balances,
|
||||
'reserveStandards' => $reserveStandards,
|
||||
'withdrawalAmounts' => $withdrawalAmounts,
|
||||
'compliance' => $compliance,
|
||||
'summary' => [
|
||||
'source' => $this->shopBalances->countForDate($balanceDate),
|
||||
'matched' => (int) $balanceCounts->get('matched', 0),
|
||||
'ambiguous' => (int) $balanceCounts->get('ambiguous', 0),
|
||||
'unmatched' => (int) $balanceCounts->get('unmatched', 0),
|
||||
'not_ready' => (int) $balanceCounts->get('not_ready', 0),
|
||||
],
|
||||
'balanceColumnLabel' => $today->format('n月j日') . '店铺余额',
|
||||
'balanceDate' => $balanceDate,
|
||||
'sort' => $sort,
|
||||
];
|
||||
}
|
||||
|
||||
/** @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'));
|
||||
|
||||
if (! in_array($balanceStatus, ['matched', 'unmatched', 'ambiguous', 'not_ready'], true)) {
|
||||
$balanceStatus = '';
|
||||
}
|
||||
|
||||
return [
|
||||
'platform' => $clean($request->query('platform')),
|
||||
'withdrawalPath' => $clean($request->query('withdrawal_path')),
|
||||
'storeEntity' => $clean($request->query('store_entity'), 191),
|
||||
'keyword' => $clean($request->query('keyword'), 191),
|
||||
'balanceStatus' => $balanceStatus,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function options(string $column): array
|
||||
{
|
||||
return StoreReceivingAccount::query()
|
||||
->whereNotNull($column)
|
||||
->where($column, '<>', '')
|
||||
->distinct()
|
||||
->orderBy($column)
|
||||
->pluck($column)
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @return array{field:string,direction:string} */
|
||||
private function sorting(Request $request): array
|
||||
{
|
||||
$field = trim((string) $request->query('sort'));
|
||||
$direction = strtolower(trim((string) $request->query('direction')));
|
||||
|
||||
return [
|
||||
'field' => in_array($field, self::SORT_FIELDS, true) ? $field : '',
|
||||
'direction' => in_array($direction, ['asc', 'desc'], true) ? $direction : 'asc',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int,StoreReceivingAccount> $rows
|
||||
* @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
|
||||
{
|
||||
$statusOrder = ['matched' => 0, 'ambiguous' => 1, 'unmatched' => 2, 'not_ready' => 3];
|
||||
|
||||
return $rows->sort(
|
||||
static function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($balances, $statusOrder): int {
|
||||
$leftStatus = $balances[(int) $left->id]['status'] ?? 'not_ready';
|
||||
$rightStatus = $balances[(int) $right->id]['status'] ?? 'not_ready';
|
||||
$statusComparison = ($statusOrder[$leftStatus] ?? 99) <=> ($statusOrder[$rightStatus] ?? 99);
|
||||
|
||||
return $statusComparison !== 0
|
||||
? $statusComparison
|
||||
: ((int) $right->id <=> (int) $left->id);
|
||||
},
|
||||
)->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int,StoreReceivingAccount> $rows
|
||||
* @param array{field:string,direction:string} $sort
|
||||
* @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 $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
|
||||
? $comparison
|
||||
: ((int) $right->id <=> (int) $left->id);
|
||||
})->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 $balances, array $reserveStandards, array $withdrawalAmounts, array $compliance): mixed
|
||||
{
|
||||
return match ($field) {
|
||||
'platform' => $row->store_platform,
|
||||
'erp_store_name' => $row->erp_store_name,
|
||||
'store_id' => $row->store_id,
|
||||
'store_entity' => $row->store_entity_name,
|
||||
'withdrawal_path' => $row->withdrawal_path,
|
||||
'withdrawal_account' => $row->withdrawal_account_name,
|
||||
'bank_card' => $row->bank_card_last_four,
|
||||
'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,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function withdrawalAmountFor(StoreReceivingAccount $row, string $balanceDate): ?float
|
||||
{
|
||||
if ($row->withdrawal_balance_date !== $balanceDate || $row->withdrawal_amount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_numeric($row->withdrawal_amount) ? (float) $row->withdrawal_amount : null;
|
||||
}
|
||||
|
||||
/** @param array{status:string,balance:?float} $balance */
|
||||
private function complianceFor(array $balance, ?int $reserve, ?float $withdrawalAmount): ?string
|
||||
{
|
||||
if (($balance['status'] ?? '') !== 'matched' || $balance['balance'] === null || $reserve === null || $withdrawalAmount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$remainingCents = (int) round((float) $balance['balance'] * 100)
|
||||
- (int) round($withdrawalAmount * 100);
|
||||
|
||||
return $remainingCents <= $reserve * 100 ? 'yes' : 'no';
|
||||
}
|
||||
|
||||
private function compareSortValues(mixed $left, mixed $right, string $direction): int
|
||||
{
|
||||
$leftEmpty = $left === null || $left === '';
|
||||
$rightEmpty = $right === null || $right === '';
|
||||
if ($leftEmpty || $rightEmpty) {
|
||||
return $leftEmpty === $rightEmpty ? 0 : ($leftEmpty ? 1 : -1);
|
||||
}
|
||||
|
||||
$comparison = is_numeric($left) && is_numeric($right)
|
||||
? ((float) $left <=> (float) $right)
|
||||
: strnatcasecmp((string) $left, (string) $right);
|
||||
|
||||
return $direction === 'desc' ? -$comparison : $comparison;
|
||||
}
|
||||
}
|
||||
469
app/Business/WithdrawalManagementSpreadsheet.php
Normal file
469
app/Business/WithdrawalManagementSpreadsheet.php
Normal file
@ -0,0 +1,469 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\StoreReceivingAccount;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Throwable;
|
||||
|
||||
class WithdrawalManagementSpreadsheet
|
||||
{
|
||||
private const CONFIG_HEADERS = [
|
||||
'店铺平台',
|
||||
'ERP店铺名称',
|
||||
'平台店铺ID',
|
||||
'店铺主体',
|
||||
'提现路径',
|
||||
'提现账户名称',
|
||||
'银行卡号(后四位)',
|
||||
];
|
||||
|
||||
private const CLEAR_VALUE = '[清空]';
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* rows:\Illuminate\Support\Collection<int,StoreReceivingAccount>,
|
||||
* 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
|
||||
*/
|
||||
public function export(array $report): StreamedResponse
|
||||
{
|
||||
$spreadsheet = $this->workbook($report);
|
||||
|
||||
return $this->download(
|
||||
$spreadsheet,
|
||||
'withdrawal_management_' . str_replace('-', '', $report['balanceDate']) . '_' . now()->format('His') . '.xlsx',
|
||||
);
|
||||
}
|
||||
|
||||
public function template(): StreamedResponse
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('提现管理');
|
||||
$this->writeHeader($sheet, [...self::CONFIG_HEADERS, '提现金额']);
|
||||
$this->sizeColumns($sheet, [14, 30, 20, 32, 16, 24, 22, 18]);
|
||||
|
||||
$instructions = $spreadsheet->createSheet();
|
||||
$instructions->setTitle('填写说明');
|
||||
$instructions->fromArray([
|
||||
['提现管理导入说明'],
|
||||
['1. 店铺平台、ERP店铺名称用于定位店铺;有平台店铺ID时优先按平台和ID匹配。'],
|
||||
['2. 店铺主体、提现路径、提现账户名称、银行卡号后四位和提现金额为可更新字段。'],
|
||||
['3. 可更新字段留空表示保持原值,填写“' . self::CLEAR_VALUE . '”表示清空原值。'],
|
||||
['4. 提现金额须为非负数,最多两位小数,只用于导入当天对应的余额数据日期;上传后先预览再确认。'],
|
||||
], null, 'A1');
|
||||
$instructions->getStyle('A1')->getFont()->setBold(true)->setSize(14);
|
||||
$instructions->getColumnDimension('A')->setWidth(88);
|
||||
$instructions->getStyle('A1:A5')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
|
||||
foreach (range(1, 5) as $row) {
|
||||
$instructions->getRowDimension($row)->setRowHeight($row === 1 ? 26 : 34);
|
||||
}
|
||||
$spreadsheet->setActiveSheetIndex(0);
|
||||
|
||||
return $this->download($spreadsheet, 'withdrawal_management_import_template.xlsx');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* total:int,changed:int,unchanged:int,
|
||||
* 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>}>
|
||||
* }
|
||||
*/
|
||||
public function preview(UploadedFile $file, int $companyId): array
|
||||
{
|
||||
try {
|
||||
$spreadsheet = IOFactory::load($file->getRealPath());
|
||||
$sheet = $spreadsheet->getSheetByName('提现管理') ?? $spreadsheet->getActiveSheet();
|
||||
$matrix = $sheet->toArray(null, false, true, false);
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
} catch (Throwable) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => 'Excel 文件无法读取,请确认文件未损坏并使用最新模板。',
|
||||
]);
|
||||
}
|
||||
[$headerIndex, $headers] = $this->headers($matrix);
|
||||
$indexes = array_flip($headers);
|
||||
$balanceDate = now()->subDay()->toDateString();
|
||||
$accounts = StoreReceivingAccount::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
[$byId, $byName] = $this->indexes($accounts);
|
||||
$errors = [];
|
||||
$plan = [];
|
||||
$preview = [];
|
||||
$seen = [];
|
||||
$total = 0;
|
||||
$unchanged = 0;
|
||||
|
||||
foreach (array_slice($matrix, $headerIndex + 1, null, true) as $matrixIndex => $row) {
|
||||
$values = [];
|
||||
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;
|
||||
}
|
||||
|
||||
$total++;
|
||||
if ($total > 5000) {
|
||||
throw ValidationException::withMessages(['import' => '单次最多导入 5000 行,请拆分文件后重试。']);
|
||||
}
|
||||
$line = $matrixIndex + 1;
|
||||
$platform = mb_substr($values['店铺平台'], 0, 80);
|
||||
$erpStoreName = mb_substr($values['ERP店铺名称'], 0, 191);
|
||||
$storeId = mb_substr(ltrim($values['平台店铺ID'], "'"), 0, 191);
|
||||
|
||||
if ($platform === '' || $erpStoreName === '') {
|
||||
$errors[] = "第 {$line} 行缺少店铺平台或 ERP 店铺名称";
|
||||
continue;
|
||||
}
|
||||
|
||||
$matches = $storeId !== ''
|
||||
? ($byId[$this->key($platform, $storeId)] ?? [])
|
||||
: ($byName[$this->key($platform, $erpStoreName)] ?? []);
|
||||
if (count($matches) === 0) {
|
||||
$identity = $storeId !== '' ? "平台店铺ID {$storeId}" : "ERP 店铺名称 {$erpStoreName}";
|
||||
$errors[] = "第 {$line} 行未找到 {$platform} / {$identity}";
|
||||
continue;
|
||||
}
|
||||
if (count($matches) > 1) {
|
||||
$errors[] = "第 {$line} 行匹配到多家店铺,请补充或核对平台店铺ID";
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @var StoreReceivingAccount $account */
|
||||
$account = $matches[0];
|
||||
if (isset($seen[(int) $account->id])) {
|
||||
$errors[] = "第 {$line} 行与第 {$seen[(int) $account->id]} 行重复指向同一家店铺";
|
||||
continue;
|
||||
}
|
||||
$seen[(int) $account->id] = $line;
|
||||
|
||||
try {
|
||||
$changes = $this->changes($account, $values, $balanceDate);
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
$errors[] = "第 {$line} 行" . $exception->getMessage();
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($changes === []) {
|
||||
$unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$labels = [
|
||||
'store_entity_name' => '店铺主体',
|
||||
'receiving_method' => '提现路径',
|
||||
'receiving_account_name' => '提现账户名称',
|
||||
'bank_card_last_four' => '银行卡号后四位',
|
||||
'withdrawal_amount' => '提现金额',
|
||||
'withdrawal_balance_date' => '提现金额',
|
||||
];
|
||||
$changedLabels = array_unique(array_map(
|
||||
static fn (string $field): string => $labels[$field],
|
||||
array_keys($changes),
|
||||
));
|
||||
$plan[] = [
|
||||
'id' => (int) $account->id,
|
||||
'updated_at' => (string) $account->getRawOriginal('updated_at'),
|
||||
'changes' => $changes,
|
||||
];
|
||||
if (count($preview) < 20) {
|
||||
$preview[] = [
|
||||
'platform' => $platform,
|
||||
'erp_store_name' => $erpStoreName,
|
||||
'store_id' => $storeId,
|
||||
'changes' => implode('、', $changedLabels),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($total === 0) {
|
||||
throw ValidationException::withMessages(['import' => '导入文件中没有可识别的数据行。']);
|
||||
}
|
||||
if ($errors !== []) {
|
||||
$visible = array_slice($errors, 0, 10);
|
||||
if (count($errors) > count($visible)) {
|
||||
$visible[] = '另有 ' . (count($errors) - count($visible)) . ' 项错误';
|
||||
}
|
||||
throw ValidationException::withMessages(['import' => implode(';', $visible) . '。']);
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'changed' => count($plan),
|
||||
'unchanged' => $unchanged,
|
||||
'preview' => $preview,
|
||||
'plan' => $plan,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<array{id:int,updated_at:string,changes:array<string,string|null>}> $plan */
|
||||
public function commit(array $plan, int $companyId): int
|
||||
{
|
||||
return DB::transaction(function () use ($plan, $companyId): int {
|
||||
$updated = 0;
|
||||
|
||||
foreach ($plan as $item) {
|
||||
/** @var StoreReceivingAccount|null $account */
|
||||
$account = StoreReceivingAccount::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->whereNull('deleted_at')
|
||||
->whereKey((int) $item['id'])
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($account === null || (string) $account->getRawOriginal('updated_at') !== (string) $item['updated_at']) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '预览后店铺配置发生变化,请重新上传并预览。',
|
||||
]);
|
||||
}
|
||||
|
||||
if (isset($item['changes']['withdrawal_balance_date'])
|
||||
&& $item['changes']['withdrawal_balance_date'] !== null
|
||||
&& $item['changes']['withdrawal_balance_date'] !== now()->subDay()->toDateString()) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '余额日期已滚动,请重新上传并预览提现金额。',
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($item['changes'] as $field => $value) {
|
||||
if ($field === 'bank_card_last_four') {
|
||||
$account->bank_card_last_four = $value;
|
||||
} else {
|
||||
$account->setAttribute($field, $value);
|
||||
}
|
||||
}
|
||||
$account->save();
|
||||
$updated++;
|
||||
}
|
||||
|
||||
return $updated;
|
||||
});
|
||||
}
|
||||
|
||||
private function workbook(array $report): Spreadsheet
|
||||
{
|
||||
$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, 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;
|
||||
$textValues = [
|
||||
1 => (string) ($row->store_platform ?? ''),
|
||||
2 => (string) ($row->erp_store_name ?? ''),
|
||||
3 => (string) ($row->store_id ?? ''),
|
||||
4 => (string) ($row->store_entity_name ?? ''),
|
||||
5 => (string) ($row->withdrawal_path ?? ''),
|
||||
6 => (string) ($row->withdrawal_account_name ?? ''),
|
||||
7 => (string) ($row->bank_card_last_four ?? ''),
|
||||
];
|
||||
foreach ($textValues as $column => $value) {
|
||||
$sheet->setCellValueExplicit([$column, $rowNumber], $value, DataType::TYPE_STRING);
|
||||
}
|
||||
|
||||
if ($match['status'] === 'matched') {
|
||||
$sheet->setCellValue([8, $rowNumber], (float) $match['balance']);
|
||||
} else {
|
||||
$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([10, $rowNumber], '待接入', DataType::TYPE_STRING);
|
||||
} else {
|
||||
$sheet->setCellValue([10, $rowNumber], $withdrawalAmount);
|
||||
}
|
||||
$sheet->setCellValueExplicit([11, $rowNumber], match ($compliance) {
|
||||
'yes' => '是',
|
||||
'no' => '否',
|
||||
default => '待核验',
|
||||
}, DataType::TYPE_STRING);
|
||||
$rowNumber++;
|
||||
}
|
||||
|
||||
if ($rowNumber > 2) {
|
||||
$sheet->getStyle('H2:J' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00');
|
||||
$sheet->getStyle('H2:J' . ($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('@');
|
||||
$spreadsheet->getProperties()->setTitle('提现管理')->setDescription('余额数据日期:' . $report['balanceDate']);
|
||||
|
||||
return $spreadsheet;
|
||||
}
|
||||
|
||||
/** @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('ERP店铺名称', $headers, true)) {
|
||||
foreach (self::CONFIG_HEADERS as $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];
|
||||
}
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '未找到提现管理导入表头,请下载最新模板。',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array{0:array<string,list<StoreReceivingAccount>>,1:array<string,list<StoreReceivingAccount>>} */
|
||||
private function indexes($accounts): array
|
||||
{
|
||||
$byId = [];
|
||||
$byName = [];
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
$platform = trim((string) $account->store_platform);
|
||||
$storeId = trim((string) ($account->store_id ?: $account->wallet_number));
|
||||
$erpStoreName = trim((string) $account->erp_store_name);
|
||||
if ($platform !== '' && $storeId !== '') {
|
||||
$byId[$this->key($platform, $storeId)][] = $account;
|
||||
}
|
||||
if ($platform !== '' && $erpStoreName !== '') {
|
||||
$byName[$this->key($platform, $erpStoreName)][] = $account;
|
||||
}
|
||||
}
|
||||
|
||||
return [$byId, $byName];
|
||||
}
|
||||
|
||||
/** @param array<string,string> $values @return array<string,string|null> */
|
||||
private function changes(StoreReceivingAccount $account, array $values, string $balanceDate): array
|
||||
{
|
||||
$fields = [
|
||||
'店铺主体' => ['field' => 'store_entity_name', 'current' => $account->store_entity_name, 'max' => 191, 'clear' => ''],
|
||||
'提现路径' => ['field' => 'receiving_method', 'current' => $account->withdrawal_path, 'max' => 191, 'clear' => ''],
|
||||
'提现账户名称' => ['field' => 'receiving_account_name', 'current' => $account->withdrawal_account_name, 'max' => 191, 'clear' => null],
|
||||
'银行卡号(后四位)' => ['field' => 'bank_card_last_four', 'current' => $account->bank_card_last_four, 'max' => 4, 'clear' => null],
|
||||
];
|
||||
$changes = [];
|
||||
|
||||
foreach ($fields as $header => $definition) {
|
||||
$raw = trim((string) ($values[$header] ?? ''));
|
||||
if ($raw === '') {
|
||||
continue;
|
||||
}
|
||||
$value = $raw === self::CLEAR_VALUE ? $definition['clear'] : $raw;
|
||||
if ($header === '银行卡号(后四位)' && $value !== null) {
|
||||
if (preg_match('/^\d{4}$/', $value) !== 1) {
|
||||
throw new \InvalidArgumentException('的银行卡号必须填写后四位数字');
|
||||
}
|
||||
}
|
||||
if ($value !== null && mb_strlen($value) > $definition['max']) {
|
||||
throw new \InvalidArgumentException("的{$header}超过 {$definition['max']} 个字符");
|
||||
}
|
||||
$current = trim((string) ($definition['current'] ?? '')) ?: null;
|
||||
$comparisonValue = $value === '' ? null : $value;
|
||||
if ($current !== $comparisonValue) {
|
||||
$changes[$definition['field']] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$amount = $values['提现金额'] ?? '';
|
||||
if ($amount !== '' && $amount !== '待接入') {
|
||||
if ($amount === self::CLEAR_VALUE) {
|
||||
if ($account->withdrawal_amount !== null || $account->withdrawal_balance_date !== null) {
|
||||
$changes['withdrawal_amount'] = null;
|
||||
$changes['withdrawal_balance_date'] = null;
|
||||
}
|
||||
} else {
|
||||
if (preg_match('/^\d{1,10}(?:\.\d{1,2})?$/', $amount) !== 1) {
|
||||
throw new \InvalidArgumentException('的提现金额须为非负数,最多两位小数');
|
||||
}
|
||||
$normalized = number_format((float) $amount, 2, '.', '');
|
||||
if ($account->withdrawal_amount !== $normalized || $account->withdrawal_balance_date !== $balanceDate) {
|
||||
$changes['withdrawal_amount'] = $normalized;
|
||||
$changes['withdrawal_balance_date'] = $balanceDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
private function key(string $platform, string $identity): string
|
||||
{
|
||||
return trim($platform) . "\0" . trim($identity);
|
||||
}
|
||||
|
||||
private function balanceStatus(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'ambiguous' => '多条匹配',
|
||||
'unmatched' => '未匹配',
|
||||
default => '待同步',
|
||||
};
|
||||
}
|
||||
|
||||
private function writeHeader($sheet, array $headers): void
|
||||
{
|
||||
$lastColumn = Coordinate::stringFromColumnIndex(count($headers));
|
||||
$sheet->fromArray($headers, null, 'A1');
|
||||
$sheet->freezePane('A2');
|
||||
$sheet->setAutoFilter("A1:{$lastColumn}1");
|
||||
$sheet->getRowDimension(1)->setRowHeight(26);
|
||||
$sheet->getStyle("A1:{$lastColumn}1")->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF');
|
||||
$sheet->getStyle("A1:{$lastColumn}1")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FF856632');
|
||||
$sheet->getStyle("A1:{$lastColumn}1")->getAlignment()->setVertical(Alignment::VERTICAL_CENTER);
|
||||
}
|
||||
|
||||
private function sizeColumns($sheet, array $widths): void
|
||||
{
|
||||
foreach ($widths as $index => $width) {
|
||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
||||
}
|
||||
}
|
||||
|
||||
private function download(Spreadsheet $spreadsheet, string $filename): StreamedResponse
|
||||
{
|
||||
return response()->streamDownload(function () use ($spreadsheet): void {
|
||||
(new Xlsx($spreadsheet))->save('php://output');
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}, $filename, [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
}
|
||||
35
app/Business/WithdrawalReserveStandard.php
Normal file
35
app/Business/WithdrawalReserveStandard.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
final class WithdrawalReserveStandard
|
||||
{
|
||||
private const TMALL_HIGH_RESERVE_STORES = [
|
||||
'天猫-亿家达旗舰店',
|
||||
'天猫-欧意朗旗舰店',
|
||||
'天猫-卓禾家具旗舰店',
|
||||
];
|
||||
|
||||
private const PLATFORM_DEFAULTS = [
|
||||
'天猫' => 20000,
|
||||
'淘宝' => 10000,
|
||||
'淘宝分销' => 10000,
|
||||
'阿里巴巴' => 10000,
|
||||
'京东' => 5000,
|
||||
'京喜' => 5000,
|
||||
'抖音' => 5000,
|
||||
'拼多多' => 5000,
|
||||
];
|
||||
|
||||
public static function amountFor(?string $platform, ?string $erpStoreName): ?int
|
||||
{
|
||||
$platform = trim((string) $platform);
|
||||
$erpStoreName = trim((string) $erpStoreName);
|
||||
|
||||
if ($platform === '天猫' && in_array($erpStoreName, self::TMALL_HIGH_RESERVE_STORES, true)) {
|
||||
return 50000;
|
||||
}
|
||||
|
||||
return self::PLATFORM_DEFAULTS[$platform] ?? null;
|
||||
}
|
||||
}
|
||||
301
app/Console/Commands/ComputeProductMonthlyWeightedAverage.php
Normal file
301
app/Console/Commands/ComputeProductMonthlyWeightedAverage.php
Normal file
@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 按取数逻辑计算某个月的更改价格、定价状态、采购单价、期初金额、入库金额和加权平均单价。
|
||||
*/
|
||||
class ComputeProductMonthlyWeightedAverage extends Command
|
||||
{
|
||||
protected $signature = 'product:monthly-weighted-average:compute {month : 报表月份,例如 2026-07}';
|
||||
|
||||
protected $description = '按取数逻辑计算商品月加权平均单价';
|
||||
|
||||
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();
|
||||
|
||||
$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));
|
||||
|
||||
$cost = $this->costPrices($connection, $codes);
|
||||
$work = $this->workPlatformPrices($connection, $codes);
|
||||
$change = $this->priceChanges($connection, $codes);
|
||||
$inbound = $this->inboundAggregate($connection, $month);
|
||||
$costRecordSkus = $this->costPriceRecordSkus($connection, $codes);
|
||||
|
||||
$total = (clone $query)->count();
|
||||
$computed = 0;
|
||||
$now = now();
|
||||
|
||||
for ($offset = 0; $offset < $total; $offset += 1000) {
|
||||
$rows = (clone $query)->orderBy('row_key')->offset($offset)->limit(1000)
|
||||
->get([
|
||||
'row_key', 'barcode_warehouse', 'warehouse_short_name', 'product_code', 'base_source', 'imported_at',
|
||||
'opening_quantity', 'opening_unit_price', 'previous_pricing_status', 'purchase_quantity',
|
||||
]);
|
||||
|
||||
$updates = [];
|
||||
foreach ($rows as $row) {
|
||||
// 普通商品资料与进销存的商品编码大小写不一致,统一按大写匹配。
|
||||
$code = mb_strtoupper(trim((string) $row->product_code));
|
||||
$costPrice = is_array($cost) ? ($cost[$code] ?? null) : null;
|
||||
$workPrice = is_array($work) ? ($work[$code] ?? null) : null;
|
||||
$changePrice = is_array($change) ? ($change[$code] ?? null) : null;
|
||||
$inboundKey = $code . "\0" . trim((string) $row->warehouse_short_name);
|
||||
$payload = $this->calculate(
|
||||
$row,
|
||||
$costPrice,
|
||||
$workPrice,
|
||||
$changePrice,
|
||||
$deadline,
|
||||
$inbound[$inboundKey] ?? null,
|
||||
isset($costRecordSkus[$code]),
|
||||
);
|
||||
$updates[] = array_merge([
|
||||
'report_month' => $month->toDateString(),
|
||||
'row_key' => $row->row_key,
|
||||
'barcode_warehouse' => $row->barcode_warehouse,
|
||||
'warehouse_short_name' => $row->warehouse_short_name,
|
||||
'product_code' => $row->product_code,
|
||||
'base_source' => $row->base_source,
|
||||
'imported_at' => $row->imported_at,
|
||||
'computed_at' => $now,
|
||||
], $payload);
|
||||
$computed++;
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
DB::connection($connection)->table($table)->upsert(
|
||||
$updates,
|
||||
['report_month', 'row_key'],
|
||||
['adjusted_unit_price', 'opening_amount', 'purchase_quantity', 'purchase_unit_price', 'purchase_amount', 'weighted_average_unit_price', 'pricing_status', 'computed_at'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DB::connection($connection)->table('product_monthly_weighted_average_states')->updateOrInsert(
|
||||
['report_month' => $month->toDateString()],
|
||||
['status' => 'computed', 'computed_rows' => $computed, 'last_computed_at' => $now, 'updated_at' => $now, 'created_at' => $now],
|
||||
);
|
||||
|
||||
$this->info($month->format('Y-m') . " 计算完成:{$computed} 行");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,float>
|
||||
*/
|
||||
private function costPrices(string $connection, array $codes): array
|
||||
{
|
||||
$map = [];
|
||||
foreach (array_chunk($codes, 2000) as $chunk) {
|
||||
foreach (DB::connection($connection)->table('ops_jst_sku')
|
||||
->whereIn('sku_id', $chunk)->whereNotNull('cost_price')->get(['sku_id', 'cost_price']) as $row) {
|
||||
$map[mb_strtoupper(trim((string) $row->sku_id))] = (float) $row->cost_price;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,array{price:float,effect:?string}>
|
||||
*/
|
||||
private function workPlatformPrices(string $connection, array $codes): array
|
||||
{
|
||||
$map = [];
|
||||
$now = now();
|
||||
foreach (array_chunk($codes, 2000) as $chunk) {
|
||||
$rows = DB::connection($connection)->table('js_pm_price_adjustment')
|
||||
->whereIn('skuCode', $chunk)
|
||||
->whereNotNull('purchasePrice')
|
||||
->where(function ($query) use ($now): void {
|
||||
$query->whereNull('effectTime')->orWhere('effectTime', '<=', $now);
|
||||
})
|
||||
->orderBy('effectTime')
|
||||
->get(['skuCode', 'purchasePrice', 'effectTime']);
|
||||
foreach ($rows as $row) {
|
||||
$map[mb_strtoupper(trim((string) $row->skuCode))] = [
|
||||
'price' => (float) $row->purchasePrice,
|
||||
'effect' => $row->effectTime === null ? null : (string) $row->effectTime,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,array{price:float,time:?string}>
|
||||
*/
|
||||
/**
|
||||
* 入库明细汇总:按「大写条码 + 仓库简称」汇总库存变动量与总计(金额 + 预估金额)。
|
||||
*
|
||||
* @return array<string,array{quantity:float,total:float}>
|
||||
*/
|
||||
private function inboundAggregate(string $connection, CarbonImmutable $month): array
|
||||
{
|
||||
$map = [];
|
||||
if (! \Illuminate\Support\Facades\Schema::connection($connection)->hasTable('inventory_inbound_detail_rows')) {
|
||||
return $map;
|
||||
}
|
||||
|
||||
foreach (DB::connection($connection)->table('inventory_inbound_detail_rows')
|
||||
->where('report_month', $month->toDateString())
|
||||
->selectRaw('warehouse_short_name, upper(barcode) as code, sum(quantity) as quantity, sum(coalesce(amount, 0) + coalesce(estimated_amount, 0)) as total')
|
||||
->groupBy('warehouse_short_name')
|
||||
->groupByRaw('upper(barcode)')
|
||||
->get() as $row) {
|
||||
$key = mb_strtoupper(trim((string) $row->code)) . "\0" . trim((string) $row->warehouse_short_name);
|
||||
$map[$key] = ['quantity' => (float) $row->quantity, 'total' => (float) $row->total];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 有"聚水潭成本价记录"(operated_at 非空)的商品编码集合。
|
||||
*
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
private function costPriceRecordSkus(string $connection, array $codes): array
|
||||
{
|
||||
$map = [];
|
||||
if (! \Illuminate\Support\Facades\Schema::connection($connection)->hasTable('jushuitan_cost_price_history_rows')) {
|
||||
return $map;
|
||||
}
|
||||
|
||||
foreach (array_chunk($codes, 2000) as $chunk) {
|
||||
$rows = DB::connection($connection)->table('jushuitan_cost_price_history_rows')
|
||||
->whereNotNull('operated_at')
|
||||
->where(function ($query) use ($chunk): void {
|
||||
$query->whereIn('sku_id', $chunk)->orWhereIn('sku_id_normalized', $chunk);
|
||||
})
|
||||
->get(['sku_id', 'sku_id_normalized']);
|
||||
foreach ($rows as $row) {
|
||||
foreach ([$row->sku_id, $row->sku_id_normalized] as $value) {
|
||||
$key = mb_strtoupper(trim((string) $value));
|
||||
if ($key !== '') {
|
||||
$map[$key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function priceChanges(string $connection, array $codes): array
|
||||
{
|
||||
$map = [];
|
||||
foreach (array_chunk($codes, 2000) as $chunk) {
|
||||
$rows = DB::connection($connection)->table('js_pm_price_adjustment_log')
|
||||
->whereIn('skucode', $chunk)
|
||||
->whereNotNull('afterPrice')
|
||||
->orderBy('effectStartTime')
|
||||
->get(['skucode', 'afterPrice', 'originalPrice', 'effectStartTime', 'createTime']);
|
||||
foreach ($rows as $row) {
|
||||
$map[mb_strtoupper(trim((string) $row->skucode))] = [
|
||||
'price' => (float) $row->afterPrice,
|
||||
'before' => $row->originalPrice === null ? null : (float) $row->originalPrice,
|
||||
'time' => $row->effectStartTime === null ? null : (string) $row->effectStartTime,
|
||||
'created' => $row->createTime === null ? null : (string) $row->createTime,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{price:float,effect:?string}|null $work
|
||||
* @param array{price:float,time:?string}|null $change
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function calculate(object $row, ?float $cost, ?array $work, ?array $change, CarbonImmutable $deadline, ?array $inbound = null, bool $hasCostRecord = false): array
|
||||
{
|
||||
$openingQuantity = $row->opening_quantity === null ? 0.0 : (float) $row->opening_quantity;
|
||||
$purchaseQuantity = $row->purchase_quantity === null ? 0.0 : (float) $row->purchase_quantity;
|
||||
$openingUnitPrice = $row->opening_unit_price === null ? null : (float) $row->opening_unit_price;
|
||||
$previousStatus = trim((string) ($row->previous_pricing_status ?? ''));
|
||||
$changePrice = $change['price'] ?? null;
|
||||
$workPrice = $work['price'] ?? null;
|
||||
// 最终规则:生效开始日期为空 → 未匹配到工作平台价格;
|
||||
// 生效开始日期 > 本月,或记录时间 > 次月2号(记录时间为空视为 ≤ 次月2号)→ 新价当月未生效,取变价前价格。
|
||||
$changeTime = $change['time'] ?? null;
|
||||
$changeCreated = $change['created'] ?? null;
|
||||
$monthStart = $deadline->startOfMonth();
|
||||
$hasWorkPlatformRecord = $changePrice !== null && $changeTime !== null;
|
||||
$newPricePending = false;
|
||||
if ($hasWorkPlatformRecord) {
|
||||
$newPricePending = CarbonImmutable::parse($changeTime)->greaterThanOrEqualTo($monthStart)
|
||||
|| ($changeCreated !== null && CarbonImmutable::parse($changeCreated)->greaterThan($deadline));
|
||||
$changePrice = $newPricePending ? ($change['before'] ?? $changePrice) : $changePrice;
|
||||
}
|
||||
$previousStatus = in_array($previousStatus, ['预估价', '未定价'], true) ? '未定价' : $previousStatus;
|
||||
|
||||
// 更改价格 + 定价状态(表2 第10条)
|
||||
if ($openingUnitPrice !== null) {
|
||||
if ($previousStatus !== '未定价') {
|
||||
$adjusted = $openingUnitPrice;
|
||||
$status = $previousStatus !== '' ? $previousStatus : '已定价';
|
||||
} elseif ($hasWorkPlatformRecord) {
|
||||
$adjusted = $changePrice;
|
||||
$status = $newPricePending ? '未定价' : '已定价';
|
||||
} else {
|
||||
$adjusted = $openingUnitPrice;
|
||||
$status = '未定价';
|
||||
}
|
||||
} elseif ($hasWorkPlatformRecord) {
|
||||
$adjusted = $changePrice;
|
||||
$status = $newPricePending ? '未定价' : '已定价';
|
||||
} elseif ($cost !== null) {
|
||||
// 方案②:只有"聚水潭成本价记录"里有生效记录(operated_at 非空)才算有生效日期 → 已定价。
|
||||
$adjusted = $cost;
|
||||
$status = $hasCostRecord ? '已定价' : '未定价';
|
||||
} else {
|
||||
$adjusted = null;
|
||||
$status = '待人工核查';
|
||||
}
|
||||
|
||||
// 采购数量/入库金额来自入库明细(大写条码 + 仓库简称汇总),采购单价 = 入库金额 ÷ 采购数量
|
||||
$purchaseQuantity = (float) ($inbound['quantity'] ?? 0);
|
||||
// 加权平均单价按“先除再舍入”计算,因此这里保留未舍入的金额参与除法。
|
||||
$purchaseAmountRaw = $inbound === null ? 0.0 : (float) ($inbound['total'] ?? 0);
|
||||
$purchaseAmount = $inbound === null ? null : round($purchaseAmountRaw, 2);
|
||||
$purchaseUnitPrice = ($inbound === null || $purchaseQuantity == 0.0)
|
||||
? null
|
||||
: round((float) ($inbound['total'] ?? 0) / $purchaseQuantity, 2);
|
||||
|
||||
$openingAmountRaw = $adjusted === null ? 0.0 : $openingQuantity * $adjusted;
|
||||
$openingAmount = $adjusted === null ? null : round($openingAmountRaw, 2);
|
||||
$denominator = $openingQuantity + $purchaseQuantity;
|
||||
if ($denominator === 0.0) {
|
||||
// 四项全 0:加权平均单价取"更改价格"(按两位舍入后的值)。
|
||||
$weighted = $adjusted === null ? null : round($adjusted, 2);
|
||||
} else {
|
||||
// 先除再舍入:用未舍入的期初金额与入库金额相除,最后统一保留两位。
|
||||
$weighted = round(($openingAmountRaw + $purchaseAmountRaw) / $denominator, 4);
|
||||
}
|
||||
|
||||
return [
|
||||
'purchase_quantity' => $purchaseQuantity,
|
||||
'adjusted_unit_price' => $adjusted === null ? null : round($adjusted, 2),
|
||||
'opening_amount' => $openingAmount,
|
||||
'purchase_unit_price' => $purchaseUnitPrice === null ? null : round($purchaseUnitPrice, 2),
|
||||
'purchase_amount' => $purchaseAmount,
|
||||
'weighted_average_unit_price' => $weighted,
|
||||
'pricing_status' => $status,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -6,14 +6,14 @@ use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\Jushuitan\ShippingReplicaSynchronizer;
|
||||
|
||||
/**
|
||||
* 一次性:删除现有发货数据里以 @ 开头、去@后已存在的重复单号。
|
||||
* 一次性:全表扫描,仅删除同订单、同发货日有干净单号对应的 @ 包裹。
|
||||
* php artisan jst:dedup-at
|
||||
*/
|
||||
class DedupAt extends Command
|
||||
{
|
||||
protected $signature = 'jst:dedup-at';
|
||||
|
||||
protected $description = '去除 @ 开头的重复单号(去@后已存在则删)';
|
||||
protected $description = '去除同订单、同发货日且有干净单号对应的 @ 包裹';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
|
||||
139
app/Console/Commands/ImportAccountLedgerRemarks.php
Normal file
139
app/Console/Commands/ImportAccountLedgerRemarks.php
Normal file
@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 导入「平台 | 账单项目 | 总账备注」映射表,供店铺收款账户账单的「总账备注」列使用。
|
||||
*/
|
||||
class ImportAccountLedgerRemarks extends Command
|
||||
{
|
||||
protected $signature = 'account-ledger-remarks:import {path : xlsx/csv 文件} {--sheet=1 : 工作表序号(从 1 开始)或名称} {--replace : 先清空再导入(默认按平台+账单项目覆盖)}';
|
||||
|
||||
protected $description = '导入账单项目对应的总账备注映射表';
|
||||
|
||||
public function handle(AccountLedgerRemarkMap $map): int
|
||||
{
|
||||
$path = (string) $this->argument('path');
|
||||
if (! is_file($path)) {
|
||||
$this->error('文件不存在:' . $path);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = $this->readRows($path);
|
||||
} catch (Throwable $e) {
|
||||
$this->error('读取失败:' . $e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
$this->error('没有解析到有效数据(需要表头:平台 / 账单项目 / 总账备注)。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
DB::transaction(function () use ($rows, $now): void {
|
||||
if ($this->option('replace')) {
|
||||
DB::table('ec_account_ledger_remarks')->delete();
|
||||
}
|
||||
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
DB::table('ec_account_ledger_remarks')->upsert(
|
||||
array_map(static fn (array $row): array => $row + ['created_at' => $now, 'updated_at' => $now], $chunk),
|
||||
['platform', 'bill_item'],
|
||||
['ledger_remark', 'updated_at'],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
$map->forget();
|
||||
|
||||
$platforms = [];
|
||||
foreach ($rows as $row) {
|
||||
$platforms[$row['platform']] = ($platforms[$row['platform']] ?? 0) + 1;
|
||||
}
|
||||
ksort($platforms);
|
||||
|
||||
$this->info(sprintf('已导入 %d 条总账备注映射。', count($rows)));
|
||||
foreach ($platforms as $platform => $count) {
|
||||
$this->line(sprintf(' %s:%d 条', $platform, $count));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{platform:string,bill_item:string,ledger_remark:string}>
|
||||
*/
|
||||
private function readRows(string $path): array
|
||||
{
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$readerType = match ($extension) {
|
||||
'xlsx' => 'Xlsx',
|
||||
'xls' => 'Xls',
|
||||
'csv' => 'Csv',
|
||||
default => throw new \InvalidArgumentException('只支持 xlsx / xls / csv 文件。'),
|
||||
};
|
||||
|
||||
$reader = IOFactory::createReader($readerType);
|
||||
$reader->setReadDataOnly(true);
|
||||
if ($readerType === 'Csv') {
|
||||
$reader->setInputEncoding('UTF-8');
|
||||
}
|
||||
$book = $reader->load($path);
|
||||
$sheetOption = trim((string) $this->option('sheet'));
|
||||
if ($sheetOption !== '' && ctype_digit($sheetOption)) {
|
||||
$index = (int) $sheetOption - 1;
|
||||
if ($index < 0 || $index >= $book->getSheetCount()) {
|
||||
throw new \InvalidArgumentException('工作表序号超出范围:' . $sheetOption);
|
||||
}
|
||||
$sheet = $book->getSheet($index);
|
||||
} else {
|
||||
$sheet = $sheetOption === '' ? $book->getActiveSheet() : $book->getSheetByName($sheetOption);
|
||||
if ($sheet === null) {
|
||||
throw new \InvalidArgumentException('找不到工作表:' . $sheetOption);
|
||||
}
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$header = null;
|
||||
foreach ($sheet->getRowIterator() as $row) {
|
||||
$cells = [];
|
||||
$cellIterator = $row->getCellIterator();
|
||||
$cellIterator->setIterateOnlyExistingCells(false);
|
||||
foreach ($cellIterator as $cell) {
|
||||
$cells[] = trim((string) $cell->getValue());
|
||||
}
|
||||
|
||||
if ($header === null) {
|
||||
$cells[0] = preg_replace('/^\xEF\xBB\xBF/', '', $cells[0] ?? '');
|
||||
$header = $cells;
|
||||
continue;
|
||||
}
|
||||
|
||||
$platform = trim($cells[0] ?? '');
|
||||
$item = trim($cells[1] ?? '');
|
||||
$remark = trim($cells[2] ?? '');
|
||||
if ($platform === '' || $item === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'platform' => mb_substr($platform, 0, 32),
|
||||
'bill_item' => mb_substr($item, 0, 191),
|
||||
'ledger_remark' => mb_substr($remark, 0, 191),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
72
app/Console/Commands/ImportDewuSettlementBills.php
Normal file
72
app/Console/Commands/ImportDewuSettlementBills.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\DewuSettlementBillImporter;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ImportDewuSettlementBills extends Command
|
||||
{
|
||||
protected $signature = 'dewu-bills:import-authoritative
|
||||
{path : 聚胜算导出的得物完整账单 XLSX}
|
||||
{--from= : 账期开始日期,格式 YYYY-MM-DD}
|
||||
{--to= : 账期结束日期,格式 YYYY-MM-DD}
|
||||
{--expected-count= : 完整账单权威笔数}
|
||||
{--expected-income= : 完整账单权威收入}
|
||||
{--expected-expense= : 完整账单权威支出}
|
||||
{--batch=1000 : 分批写入行数}';
|
||||
|
||||
protected $description = '导入并核验聚胜算得物完整账单(保留全部渠道)';
|
||||
|
||||
public function handle(DewuSettlementBillImporter $importer): int
|
||||
{
|
||||
foreach (['from', 'to', 'expected-count', 'expected-income', 'expected-expense'] as $option) {
|
||||
if (trim((string) $this->option($option)) === '') {
|
||||
$this->error("缺少 --{$option},完整账单导入必须提供账期和三项权威控制数。");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('path'),
|
||||
(string) $this->option('from'),
|
||||
(string) $this->option('to'),
|
||||
(int) $this->option('expected-count'),
|
||||
(string) $this->option('expected-income'),
|
||||
(string) $this->option('expected-expense'),
|
||||
(int) $this->option('batch'),
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('得物完整账单导入失败,详细原因已记录到受保护日志。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['状态', '账单行数', '跳过行数', '店铺数', '账单项目数', '收入', '支出', '净额', '账期'], [[
|
||||
$result['status'],
|
||||
$result['row_count'],
|
||||
$result['excluded_rows'],
|
||||
$result['store_count'],
|
||||
$result['bill_item_count'],
|
||||
$result['income_total'],
|
||||
$result['expense_total'],
|
||||
$result['net_total'],
|
||||
$result['period_start'] . ' 至 ' . $result['period_end'],
|
||||
]]);
|
||||
$this->line('渠道:' . collect($result['channel_counts'])
|
||||
->map(fn (int $count, string $channel): string => "{$channel} {$count}")
|
||||
->implode(';'));
|
||||
$this->line('SHA-256: ' . $result['source_sha256']);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -9,21 +9,22 @@ use Throwable;
|
||||
class ImportDouyinAuthoritativeBills extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:import-douyin-authoritative
|
||||
{path : 胜算原始明细 UTF-8 CSV} {month : 完整月份 YYYY-MM}
|
||||
{--count= : 核对笔数} {--income= : 核对收入合计} {--expense= : 核对支出合计}';
|
||||
{path : 胜算原始明细 UTF-8 CSV} {month : 所属月份 YYYY-MM}
|
||||
{--count= : 核对笔数} {--income= : 核对收入合计} {--expense= : 核对支出合计}
|
||||
{--through= : 月初至此日期的完整数据 YYYY-MM-DD,省略时为整月}';
|
||||
|
||||
protected $description = '校验并原子启用完整月份胜算抖音明细,保留旧版本用于回滚';
|
||||
protected $description = '校验并原子启用指定月份或月初至截止日的胜算抖音明细,保留旧版本用于回滚';
|
||||
|
||||
public function handle(DouyinAuthoritativeBillImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import((string) $this->argument('path'), (string) $this->argument('month'),
|
||||
(int) $this->option('count'), (string) $this->option('income'), (string) $this->option('expense'));
|
||||
(int) $this->option('count'), (string) $this->option('income'), (string) $this->option('expense'), $this->option('through'));
|
||||
} catch (Throwable $e) {
|
||||
$this->error('胜算抖音明细未启用,保留上一版数据。' . ($e instanceof \InvalidArgumentException ? $e->getMessage() : '请检查文件和镜像连接。'));
|
||||
return self::FAILURE;
|
||||
}
|
||||
$this->info('胜算抖音明细已启用,月份:' . $this->argument('month') . ',笔数:' . $result['row_count']);
|
||||
$this->info('胜算抖音明细已启用,范围:[' . $result['period_start'] . ', ' . $result['period_end'] . '),笔数:' . $result['row_count']);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
36
app/Console/Commands/ImportInternalFlowReport.php
Normal file
36
app/Console/Commands/ImportInternalFlowReport.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\InternalFlowWorkbookImporter;
|
||||
use Throwable;
|
||||
|
||||
class ImportInternalFlowReport extends Command
|
||||
{
|
||||
protected $signature = 'internal-flow:import
|
||||
{path : 「一般纳税人内部流转」工作簿路径(.xlsx)}';
|
||||
|
||||
protected $description = '导入内部流转报表明细,整份替换共享业务镜像中的上一版数据';
|
||||
|
||||
public function handle(InternalFlowWorkbookImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import((string) $this->argument('path'));
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('内部流转报表导入失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'内部流转报表导入完成:明细 %s 行,跳过合计/空行 %s 行,来源文件 %s。',
|
||||
number_format($result['imported']),
|
||||
number_format($result['skipped']),
|
||||
$result['source_file'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
210
app/Console/Commands/ImportInventoryInboundDetail.php
Normal file
210
app/Console/Commands/ImportInventoryInboundDetail.php
Normal file
@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* 进销存报表 → 入库明细数据:按原始 Excel 原样导入,仓库简称用仓库名称映射表按「仓库」唯一匹配。
|
||||
*/
|
||||
class ImportInventoryInboundDetail extends Command
|
||||
{
|
||||
protected $signature = 'inventory:inbound-detail:import {file : 入库明细表 Excel} {--month= : 统一指定月份(不传则按创建时间判断)}';
|
||||
|
||||
protected $description = '导入进销存报表的入库明细数据';
|
||||
|
||||
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();
|
||||
|
||||
$maps = $this->shortNameMap();
|
||||
$this->info('仓库名称映射:仓库名 ' . count($maps['warehouse']) . ' 个、仓储方 ' . count($maps['provider']) . ' 个可唯一匹配');
|
||||
|
||||
// 原样导入:以本次文件为准整体重建,不做去重、不做加工。
|
||||
DB::connection($connection)->table($table)->delete();
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\InventoryReportAuthoritativeImporter;
|
||||
use Throwable;
|
||||
|
||||
class ImportInventoryReportAuthoritativeMonth extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:import-inventory-report-month
|
||||
{path : 聚水潭按商品、按仓库、按月多维分析完整 XLSX 导出文件}';
|
||||
|
||||
protected $description = '导入进销存报表权威月明细,保留仓储方和子仓维度';
|
||||
|
||||
public function handle(InventoryReportAuthoritativeImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import((string) $this->argument('path'));
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('进销存权威月报导入失败:' . $exception->getMessage());
|
||||
$this->warn('已保留上一版有效数据。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'进销存权威月报导入完成:月份 %s,明细 %d 行。',
|
||||
$result['source_month'],
|
||||
$result['source_rows'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
37
app/Console/Commands/ImportJdDailyBills.php
Normal file
37
app/Console/Commands/ImportJdDailyBills.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\EcommerceFinance\Services\JdDailyBillImporter;
|
||||
use Throwable;
|
||||
|
||||
class ImportJdDailyBills extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:import-jd-daily
|
||||
{path : 胜算京东日账单指定月份月初至截止日期 UTF-8 CSV} {month : YYYY-MM}
|
||||
{--through= : 截止日期 YYYY-MM-DD,默认月末} {--count= : 核对笔数} {--income= : 核对收入} {--expense= : 核对支出}';
|
||||
|
||||
protected $description = '核对并启用胜算京东日账单月份镜像,保留旧版本';
|
||||
|
||||
public function handle(JdDailyBillImporter $importer): int
|
||||
{
|
||||
$lock = Cache::lock('jd-daily-bill-import', 21600);
|
||||
if (! $lock->get()) {
|
||||
$this->error('京东日账单正在导入,请稍后重试。');
|
||||
return self::FAILURE;
|
||||
}
|
||||
try {
|
||||
$result = $importer->import((string) $this->argument('path'), (string) $this->argument('month'),
|
||||
(int) $this->option('count'), (string) $this->option('income'), (string) $this->option('expense'), $this->option('through'));
|
||||
$this->info('京东日账单已启用,笔数:' . $result['row_count']);
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$this->error('京东日账单未启用,保留上一版本。' . ($e instanceof \InvalidArgumentException ? $e->getMessage() : '请检查文件、重复记录及镜像连接。'));
|
||||
return self::FAILURE;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/Console/Commands/ImportJdWalletBills.php
Normal file
37
app/Console/Commands/ImportJdWalletBills.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\EcommerceFinance\Services\JdWalletBillImporter;
|
||||
use Throwable;
|
||||
|
||||
class ImportJdWalletBills extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:import-jd-wallet
|
||||
{path : 胜算京东钱包指定月份月初至截止日期 UTF-8 CSV} {month : YYYY-MM}
|
||||
{--through= : 截止日期 YYYY-MM-DD,默认月末} {--count= : 核对笔数} {--income= : 核对收入} {--expense= : 核对支出}';
|
||||
|
||||
protected $description = '核对并启用胜算京东钱包月份镜像,保留旧版本';
|
||||
|
||||
public function handle(JdWalletBillImporter $importer): int
|
||||
{
|
||||
$lock = Cache::lock('jd-wallet-bill-import', 21600);
|
||||
if (! $lock->get()) {
|
||||
$this->error('京东钱包正在导入,请稍后重试。');
|
||||
return self::FAILURE;
|
||||
}
|
||||
try {
|
||||
$result = $importer->import((string) $this->argument('path'), (string) $this->argument('month'),
|
||||
(int) $this->option('count'), (string) $this->option('income'), (string) $this->option('expense'), $this->option('through'));
|
||||
$this->info('京东钱包已启用,笔数:' . $result['row_count']);
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$this->error('京东钱包未启用,保留上一版本。' . ($e instanceof \InvalidArgumentException ? $e->getMessage() : '请检查文件、重复记录及镜像连接。'));
|
||||
return self::FAILURE;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
42
app/Console/Commands/ImportJstOrderLabels.php
Normal file
42
app/Console/Commands/ImportJstOrderLabels.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\JstOrderLabelImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ImportJstOrderLabels extends Command
|
||||
{
|
||||
protected $signature = 'jst-order-labels:import
|
||||
{--from= : 起始下单日期 YYYY-MM-DD}
|
||||
{--to= : 结束下单日期 YYYY-MM-DD}
|
||||
{--company=1 : 公司 ID}
|
||||
{--replace : 先清空该区间已有行}';
|
||||
|
||||
protected $description = '把聚水潭订单按标签拆行导入「聚水潭订单标签明细」(账单流水数据页)';
|
||||
|
||||
public function handle(JstOrderLabelImporter $importer): int
|
||||
{
|
||||
$from = (string) $this->option('from');
|
||||
$to = (string) $this->option('to');
|
||||
|
||||
try {
|
||||
$result = $importer->import(
|
||||
$from,
|
||||
$to,
|
||||
(int) $this->option('company'),
|
||||
(bool) $this->option('replace'),
|
||||
'jst-order-label-import',
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(json_encode($result, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
72
app/Console/Commands/ImportPinduoduoSettlementBills.php
Normal file
72
app/Console/Commands/ImportPinduoduoSettlementBills.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\PinduoduoSettlementBillImporter;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ImportPinduoduoSettlementBills extends Command
|
||||
{
|
||||
protected $signature = 'pinduoduo-bills:import-authoritative
|
||||
{path : 聚胜算导出的拼多多完整账单 XLSX}
|
||||
{--from= : 账期开始日期,格式 YYYY-MM-DD}
|
||||
{--to= : 账期结束日期,格式 YYYY-MM-DD}
|
||||
{--expected-count= : 完整账单权威笔数}
|
||||
{--expected-income= : 完整账单权威收入}
|
||||
{--expected-expense= : 完整账单权威支出}
|
||||
{--batch=1000 : 分批写入行数}';
|
||||
|
||||
protected $description = '导入并核验聚胜算拼多多完整账单(保留全部账户类型)';
|
||||
|
||||
public function handle(PinduoduoSettlementBillImporter $importer): int
|
||||
{
|
||||
foreach (['from', 'to', 'expected-count', 'expected-income', 'expected-expense'] as $option) {
|
||||
if (trim((string) $this->option($option)) === '') {
|
||||
$this->error("缺少 --{$option},完整账单导入必须提供账期和三项权威控制数。");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('path'),
|
||||
(string) $this->option('from'),
|
||||
(string) $this->option('to'),
|
||||
(int) $this->option('expected-count'),
|
||||
(string) $this->option('expected-income'),
|
||||
(string) $this->option('expected-expense'),
|
||||
(int) $this->option('batch'),
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('拼多多完整货款账单导入失败,详细原因已记录到受保护日志。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['状态', '账单行数', '跳过行数', '店铺数', '账单项目数', '收入', '支出', '净额', '账期'], [[
|
||||
$result['status'],
|
||||
$result['row_count'],
|
||||
$result['excluded_account_rows'],
|
||||
$result['store_count'],
|
||||
$result['bill_item_count'],
|
||||
$result['income_total'],
|
||||
$result['expense_total'],
|
||||
$result['net_total'],
|
||||
$result['period_start'] . ' 至 ' . $result['period_end'],
|
||||
]]);
|
||||
$this->line('账户类型:' . collect($result['account_type_counts'])
|
||||
->map(fn (int $count, string $account): string => "{$account} {$count}")
|
||||
->implode(';'));
|
||||
$this->line('SHA-256: ' . $result['source_sha256']);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
38
app/Console/Commands/ImportPlantingDetails.php
Normal file
38
app/Console/Commands/ImportPlantingDetails.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\PlantingDetailImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ImportPlantingDetails extends Command
|
||||
{
|
||||
protected $signature = 'planting-details:import
|
||||
{file : xlsx / xls / csv 文件路径}
|
||||
{--company=1 : 公司 ID}
|
||||
{--replace : 先清空该公司已有的种菜明细}';
|
||||
|
||||
protected $description = '导入「种菜明细表」数据(账单流水数据 › 种菜明细表)';
|
||||
|
||||
public function handle(PlantingDetailImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('file'),
|
||||
(int) $this->option('company'),
|
||||
(bool) $this->option('replace'),
|
||||
null,
|
||||
'planting-detail-import',
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(json_encode($result, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
333
app/Console/Commands/ImportProductMonthlyWeightedAverageBase.php
Normal file
333
app/Console/Commands/ImportProductMonthlyWeightedAverageBase.php
Normal file
@ -0,0 +1,333 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* 商品月加权平均单价:导入某个月的期初基数。
|
||||
*
|
||||
* 基数来源两类:
|
||||
* 1) 首个可用月份(7月)来自财务提供的《商品月末加权平均单价》表1,包含仓库简称、商品编码和上月加权平均单价;
|
||||
* 2) 其余月份由上月算出的加权平均单价滚动生成(--from-previous)。
|
||||
* 无论哪一类,都会并入进销存总表里"代发仓"的商品(代发仓没有库存,采购数量取销售出库)。
|
||||
*/
|
||||
class ImportProductMonthlyWeightedAverageBase extends Command
|
||||
{
|
||||
protected $signature = 'product:monthly-weighted-average:base
|
||||
{month : 报表月份,例如 2026-07}
|
||||
{--file= : 期初基数 Excel(表1),首月使用}
|
||||
{--from-previous : 用上月的加权平均单价作为本月原始价格}';
|
||||
|
||||
protected $description = '导入/生成商品月加权平均单价的期初基数';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$rowsTable = 'product_monthly_weighted_average_rows';
|
||||
$now = now();
|
||||
|
||||
$inventory = $this->inventoryAggregate($connection, $month);
|
||||
$jiangxiPurchase = $this->jiangxiDeliveryPurchase($connection, $month);
|
||||
$nanchangOpening = $this->nanchangOpeningQuantity($connection, $month);
|
||||
$this->info('进销存(排除不看)合计 ' . count($inventory) . ' 个 仓库简称+商品编码');
|
||||
|
||||
DB::connection($connection)->table($rowsTable)->where('report_month', $month->toDateString())->delete();
|
||||
|
||||
$payload = [];
|
||||
$baseRows = 0;
|
||||
$deliveryRows = 0;
|
||||
$mirrorRows = 0;
|
||||
|
||||
$file = (string) ($this->option('file') ?? '');
|
||||
if ($file !== '') {
|
||||
foreach ($this->readBaseFile($file) as $row) {
|
||||
$payload[$this->rowKey($month, $row['warehouse_short_name'], $row['product_code'])] = [
|
||||
'report_month' => $month->toDateString(),
|
||||
'row_key' => $this->rowKey($month, $row['warehouse_short_name'], $row['product_code']),
|
||||
'barcode_warehouse' => $row['barcode_warehouse'],
|
||||
'warehouse_short_name' => $row['warehouse_short_name'],
|
||||
'product_code' => $row['product_code'],
|
||||
'opening_unit_price' => $row['opening_unit_price'],
|
||||
'previous_pricing_status' => $row['previous_pricing_status'],
|
||||
'base_source' => 'base',
|
||||
'imported_at' => $now,
|
||||
];
|
||||
$baseRows++;
|
||||
}
|
||||
} elseif ($this->option('from-previous')) {
|
||||
$previous = $month->subMonthNoOverflow();
|
||||
foreach (DB::connection($connection)->table($rowsTable)
|
||||
->where('report_month', $previous->toDateString())
|
||||
->get(['barcode_warehouse', 'warehouse_short_name', 'product_code', 'pricing_status', 'weighted_average_unit_price']) as $row) {
|
||||
$key = $this->rowKey($month, $row->warehouse_short_name, $row->product_code);
|
||||
$payload[$key] = [
|
||||
'report_month' => $month->toDateString(),
|
||||
'row_key' => $key,
|
||||
'barcode_warehouse' => $row->barcode_warehouse,
|
||||
'warehouse_short_name' => $row->warehouse_short_name,
|
||||
'product_code' => $row->product_code,
|
||||
'opening_unit_price' => $row->weighted_average_unit_price,
|
||||
'previous_pricing_status' => $row->pricing_status,
|
||||
'base_source' => 'base',
|
||||
'imported_at' => $now,
|
||||
];
|
||||
$baseRows++;
|
||||
}
|
||||
} else {
|
||||
$this->error('请给出 --file=表1.xlsx(首月)或 --from-previous(滚动月份)。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// 代发仓:没有库存,但必须出现在这张表里,采购数量取销售出库。
|
||||
foreach ($inventory as $key => $item) {
|
||||
$rowKey = $this->rowKey($month, $item['warehouse_short_name'], $item['product_code']);
|
||||
if (isset($payload[$rowKey])) {
|
||||
continue;
|
||||
}
|
||||
$isDelivery = $this->isDeliveryWarehouse($item['warehouse_short_name']);
|
||||
$payload[$rowKey] = [
|
||||
'report_month' => $month->toDateString(),
|
||||
'row_key' => $rowKey,
|
||||
// 条码+仓库 = 商品编码 + 仓库简称
|
||||
'barcode_warehouse' => $item['product_code'] . $item['warehouse_short_name'],
|
||||
'warehouse_short_name' => $item['warehouse_short_name'],
|
||||
'product_code' => $item['product_code'],
|
||||
'opening_unit_price' => null,
|
||||
'previous_pricing_status' => null,
|
||||
'base_source' => $isDelivery ? 'delivery' : 'mirror',
|
||||
'imported_at' => $now,
|
||||
];
|
||||
$isDelivery ? $deliveryRows++ : $mirrorRows++;
|
||||
}
|
||||
|
||||
// 南昌樵舍仓的采购数量取自"简尚-江西仓送货主仓",这些条码也要一并纳入行维度,
|
||||
// 否则只存在于江西仓送货的单据会因为表里没有对应行而丢掉采购数量。
|
||||
foreach (array_keys($jiangxiPurchase) as $code) {
|
||||
$rowKey = $this->rowKey($month, '南昌樵舍仓', $code);
|
||||
if (isset($payload[$rowKey])) {
|
||||
continue;
|
||||
}
|
||||
$payload[$rowKey] = [
|
||||
'report_month' => $month->toDateString(),
|
||||
'row_key' => $rowKey,
|
||||
'barcode_warehouse' => $code . '南昌樵舍仓',
|
||||
'warehouse_short_name' => '南昌樵舍仓',
|
||||
'product_code' => $code,
|
||||
'opening_unit_price' => null,
|
||||
'previous_pricing_status' => null,
|
||||
'base_source' => 'mirror',
|
||||
'imported_at' => $now,
|
||||
];
|
||||
$mirrorRows++;
|
||||
}
|
||||
|
||||
$matched = 0;
|
||||
foreach ($payload as $key => $row) {
|
||||
$item = $inventory[$row['warehouse_short_name'] . "\0" . $row['product_code']] ?? null;
|
||||
// 代发仓判定不能只看 base_source(滚动月份会丢标记),按仓库简称再兜一层。
|
||||
$isDelivery = $row['base_source'] === 'delivery'
|
||||
|| str_contains((string) $row['warehouse_short_name'], '代发仓');
|
||||
$isNanchang = $row['warehouse_short_name'] === '南昌樵舍仓';
|
||||
// 原始数量只取"主仓"的期初数量(仓库名称含主仓字样)。
|
||||
$openingQuantity = $isDelivery ? 0.0 : (float) ($item['opening_quantity'] ?? 0);
|
||||
if ($isNanchang) {
|
||||
// 南昌樵舍仓是特殊仓库:期初数量取"三方-南昌樵舍仓主仓"对应商品的期初数量。
|
||||
$openingQuantity = (float) ($nanchangOpening[$row['product_code']] ?? 0);
|
||||
}
|
||||
$purchaseQuantity = $isDelivery
|
||||
? abs((float) ($item['sales_out'] ?? 0))
|
||||
: ($isNanchang
|
||||
// 南昌樵舍仓是特殊仓库:采购数量取"简尚-江西仓送货主仓"对应商品的采购进仓数量。
|
||||
? (float) ($jiangxiPurchase[$row['product_code']] ?? 0)
|
||||
: (float) ($item['purchase_in'] ?? 0));
|
||||
if ($item !== null) {
|
||||
$matched++;
|
||||
}
|
||||
$payload[$key]['product_name'] = $item['category'] ?? null;
|
||||
$payload[$key]['opening_quantity'] = $openingQuantity;
|
||||
$payload[$key]['purchase_quantity'] = $purchaseQuantity;
|
||||
$payload[$key]['opening_original_amount'] = $row['opening_unit_price'] === null
|
||||
? null
|
||||
: round($openingQuantity * (float) $row['opening_unit_price'], 2);
|
||||
}
|
||||
|
||||
foreach (array_chunk($payload, 1000, true) as $chunk) {
|
||||
DB::connection($connection)->table($rowsTable)->insert(array_values($chunk));
|
||||
}
|
||||
|
||||
DB::connection($connection)->table('product_monthly_weighted_average_states')->updateOrInsert(
|
||||
['report_month' => $month->toDateString()],
|
||||
[
|
||||
'status' => 'base_imported',
|
||||
'base_filename' => $file !== '' ? basename($file) : null,
|
||||
'base_rows' => $baseRows,
|
||||
'delivery_rows' => $deliveryRows,
|
||||
'matched_rows' => $matched,
|
||||
'last_error' => null,
|
||||
'updated_at' => $now,
|
||||
'created_at' => $now,
|
||||
],
|
||||
);
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s 基数导入完成:基表 %d 行 + 代发仓 %d 行,合计 %d 行,其中 %d 行在进销存总表匹配到数据。',
|
||||
$month->format('Y-m'),
|
||||
$baseRows,
|
||||
$deliveryRows,
|
||||
count($payload),
|
||||
$matched,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return array<string,array{warehouse_short_name:string,product_code:string,opening_quantity:string,purchase_in:string,sales_out:string,category:string}> */
|
||||
private function inventoryAggregate(string $connection, CarbonImmutable $month): array
|
||||
{
|
||||
$generation = DB::connection($connection)->table('inventory_report_authoritative_months')
|
||||
->where('source_month', $month->toDateString())
|
||||
->value('active_generation');
|
||||
if (! $generation) {
|
||||
throw new \RuntimeException($month->format('Y-m') . ' 的进销存总表尚未导入。');
|
||||
}
|
||||
|
||||
$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,
|
||||
sum(r.purchase_in) as purchase_in,
|
||||
sum(r.sales_out) as sales_out, max(r.category) as category
|
||||
from inventory_report_authoritative_rows r
|
||||
join {$mappingTable} m
|
||||
on m.warehouse_provider = r.warehouse_provider
|
||||
and m.warehouse = r.warehouse
|
||||
and m.company_id = 1
|
||||
and m.deleted_at is null
|
||||
where r.generation = ?
|
||||
and m.warehouse_short_name not like '%不看%'
|
||||
and r.warehouse like '%主仓%'
|
||||
and m.warehouse_short_name <> '南昌樵舍仓'
|
||||
group by m.warehouse_short_name, r.product_code";
|
||||
|
||||
$aggregate = [];
|
||||
foreach (DB::connection($connection)->select($sql, [$generation]) as $row) {
|
||||
$aggregate[$row->short_name . "\0" . mb_strtoupper(trim((string) $row->product_code))] = [
|
||||
'warehouse_short_name' => (string) $row->short_name,
|
||||
// 商品编码统一按大写建索引,避免与表1/普通商品资料大小写不一致导致匹配不上。
|
||||
'product_code' => mb_strtoupper(trim((string) $row->product_code)),
|
||||
'opening_quantity' => (string) $row->opening_quantity,
|
||||
'purchase_in' => (string) $row->purchase_in,
|
||||
'sales_out' => (string) $row->sales_out,
|
||||
'category' => (string) ($row->category ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $aggregate;
|
||||
}
|
||||
|
||||
/** @return \Generator<int,array{barcode_warehouse:string,warehouse_short_name:string,product_code:string,opening_unit_price:?float}> */
|
||||
private function readBaseFile(string $file): \Generator
|
||||
{
|
||||
$reader = IOFactory::createReaderForFile($file);
|
||||
$reader->setReadDataOnly(true);
|
||||
$spreadsheet = $reader->load($file);
|
||||
$sheet = $spreadsheet->getSheetByName('7月份期初基数') ?? $spreadsheet->getSheet(0);
|
||||
|
||||
foreach ($sheet->getRowIterator(3) as $row) {
|
||||
$cells = [];
|
||||
foreach ($row->getCellIterator('A', 'P') as $cell) {
|
||||
$cells[] = $cell->getFormattedValue();
|
||||
}
|
||||
$shortName = trim((string) ($cells[2] ?? ''));
|
||||
$productCode = trim((string) ($cells[4] ?? ''));
|
||||
if ($shortName === '' || $productCode === '') {
|
||||
continue;
|
||||
}
|
||||
$price = trim((string) ($cells[7] ?? ''));
|
||||
$previousStatus = trim((string) ($cells[15] ?? ''));
|
||||
|
||||
yield [
|
||||
// 条码+仓库统一为「仓库简称 + 商品编码」,不再沿用表1 里的单元格写法。
|
||||
'barcode_warehouse' => $shortName . $productCode,
|
||||
'warehouse_short_name' => $shortName,
|
||||
'product_code' => mb_strtoupper($productCode),
|
||||
// 原始价格保留源表精度(不提前舍入),保证加权平均单价与手工表一致;页面展示仍按两位。
|
||||
'opening_unit_price' => $price === '' ? null : (float) str_replace(',', '', $price),
|
||||
'previous_pricing_status' => $previousStatus === '' ? null : $previousStatus,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function isDeliveryWarehouse(string $shortName): bool
|
||||
{
|
||||
return str_contains($shortName, '代发');
|
||||
}
|
||||
|
||||
/**
|
||||
* 南昌樵舍仓的采购数量取"简尚-江西仓送货主仓"对应商品的采购进仓数量。
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function jiangxiDeliveryPurchase(string $connection, CarbonImmutable $month): array
|
||||
{
|
||||
$generation = DB::connection($connection)->table('inventory_report_authoritative_months')
|
||||
->where('source_month', $month->toDateString())
|
||||
->value('active_generation');
|
||||
if (! $generation) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach (DB::connection($connection)->table('inventory_report_authoritative_rows')
|
||||
->where('generation', $generation)
|
||||
->where('warehouse', '简尚-江西仓送货主仓')
|
||||
->selectRaw('product_code, sum(purchase_in) as quantity')
|
||||
->groupBy('product_code')
|
||||
->get() as $row) {
|
||||
$map[mb_strtoupper(trim((string) $row->product_code))] = (string) $row->quantity;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 南昌樵舍仓的期初数量取"三方-南昌樵舍仓主仓"对应商品的期初数量。
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function nanchangOpeningQuantity(string $connection, CarbonImmutable $month): array
|
||||
{
|
||||
$generation = DB::connection($connection)->table('inventory_report_authoritative_months')
|
||||
->where('source_month', $month->toDateString())
|
||||
->value('active_generation');
|
||||
if (! $generation) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach (DB::connection($connection)->table('inventory_report_authoritative_rows')
|
||||
->where('generation', $generation)
|
||||
->where('warehouse', '三方-南昌樵舍仓主仓')
|
||||
->selectRaw('product_code, sum(opening_quantity) as quantity')
|
||||
->groupBy('product_code')
|
||||
->get() as $row) {
|
||||
$map[mb_strtoupper(trim((string) $row->product_code))] = (string) $row->quantity;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function rowKey(CarbonImmutable $month, string $shortName, string $productCode): string
|
||||
{
|
||||
return sha1($month->format('Y-m') . '|' . $shortName . '|' . $productCode);
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,8 @@ class ImportTaobaoAlipayBills extends Command
|
||||
{
|
||||
protected $signature = 'business:import-taobao-alipay-bills
|
||||
{file : 淘系支付宝账户 Excel 文件路径}
|
||||
{--company=1 : 数据所属公司 ID}';
|
||||
{--company=1 : 数据所属公司 ID}
|
||||
{--replace : 校验通过后替换该公司的全部淘系支付宝账单}';
|
||||
|
||||
protected $description = '从标准附件导入店铺收款账户账单的淘系支付宝账户数据';
|
||||
|
||||
@ -20,6 +21,7 @@ class ImportTaobaoAlipayBills extends Command
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('file'),
|
||||
max(1, (int) $this->option('company')),
|
||||
(bool) $this->option('replace'),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$this->components->error($e->getMessage());
|
||||
@ -28,7 +30,7 @@ class ImportTaobaoAlipayBills extends Command
|
||||
}
|
||||
|
||||
$this->components->info(
|
||||
"导入完成:新增 {$result['inserted']} 条,更新 {$result['updated']} 条,跳过 {$result['skipped']} 条。"
|
||||
"导入完成:新增 {$result['inserted']} 条,更新 {$result['updated']} 条,替换旧数据 {$result['replaced']} 条,跳过 {$result['skipped']} 条。"
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
|
||||
71
app/Console/Commands/ImportTaobaoAlipaySettlementBills.php
Normal file
71
app/Console/Commands/ImportTaobaoAlipaySettlementBills.php
Normal file
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\TaobaoAlipaySettlementBillImporter;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ImportTaobaoAlipaySettlementBills extends Command
|
||||
{
|
||||
protected $signature = 'taobao-alipay-bills:import-authoritative
|
||||
{path : 聚胜算导出的淘系支付宝完整账单 XLSX}
|
||||
{--from= : 账期开始日期,格式 YYYY-MM-DD}
|
||||
{--to= : 账期结束日期,格式 YYYY-MM-DD}
|
||||
{--expected-count= : 完整账单权威笔数}
|
||||
{--expected-income= : 完整账单权威收入}
|
||||
{--expected-expense= : 完整账单权威支出}
|
||||
{--batch=1000 : 分批写入行数}';
|
||||
|
||||
protected $description = '导入并核验聚胜算淘系支付宝完整账单';
|
||||
|
||||
public function handle(TaobaoAlipaySettlementBillImporter $importer): int
|
||||
{
|
||||
foreach (['from', 'to', 'expected-count', 'expected-income', 'expected-expense'] as $option) {
|
||||
if (trim((string) $this->option($option)) === '') {
|
||||
$this->error("缺少 --{$option},完整账单导入必须提供账期和三项权威控制数。");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('path'),
|
||||
(string) $this->option('from'),
|
||||
(string) $this->option('to'),
|
||||
(int) $this->option('expected-count'),
|
||||
(string) $this->option('expected-income'),
|
||||
(string) $this->option('expected-expense'),
|
||||
(int) $this->option('batch'),
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('淘系支付宝完整账单导入失败,详细原因已记录到受保护日志。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['状态', '账单行数', '店铺数', '账单项目数', '收入', '支出', '净额', '账期'], [[
|
||||
$result['status'],
|
||||
$result['row_count'],
|
||||
$result['store_count'],
|
||||
$result['bill_item_count'],
|
||||
$result['income_total'],
|
||||
$result['expense_total'],
|
||||
$result['net_total'],
|
||||
$result['period_start'] . ' 至 ' . $result['period_end'],
|
||||
]]);
|
||||
$this->line('业务类型:' . collect($result['business_type_counts'])
|
||||
->map(fn (int $count, string $type): string => "{$type} {$count}")
|
||||
->implode(';'));
|
||||
$this->line('SHA-256: ' . $result['source_sha256']);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
72
app/Console/Commands/ImportTaobaoWechatSettlementBills.php
Normal file
72
app/Console/Commands/ImportTaobaoWechatSettlementBills.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\TaobaoWechatSettlementBillImporter;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ImportTaobaoWechatSettlementBills extends Command
|
||||
{
|
||||
protected $signature = 'taobao-wechat-bills:import-authoritative
|
||||
{path : 聚胜算导出的淘系微信完整账单 XLSX}
|
||||
{--from= : 账期开始日期,格式 YYYY-MM-DD}
|
||||
{--to= : 账期结束日期,格式 YYYY-MM-DD}
|
||||
{--expected-count= : 完整账单权威笔数}
|
||||
{--expected-income= : 完整账单权威收入}
|
||||
{--expected-expense= : 完整账单权威支出}
|
||||
{--batch=1000 : 分批写入行数}';
|
||||
|
||||
protected $description = '导入并核验聚胜算淘系微信完整账单';
|
||||
|
||||
public function handle(TaobaoWechatSettlementBillImporter $importer): int
|
||||
{
|
||||
foreach (['from', 'to', 'expected-count', 'expected-income', 'expected-expense'] as $option) {
|
||||
if (trim((string) $this->option($option)) === '') {
|
||||
$this->error("缺少 --{$option},完整账单导入必须提供账期和三项权威控制数。");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('path'),
|
||||
(string) $this->option('from'),
|
||||
(string) $this->option('to'),
|
||||
(int) $this->option('expected-count'),
|
||||
(string) $this->option('expected-income'),
|
||||
(string) $this->option('expected-expense'),
|
||||
(int) $this->option('batch'),
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('淘系微信完整账单导入失败,详细原因已记录到受保护日志。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['状态', '账单行数', '跳过行数', '店铺数', '账单项目数', '收入', '支出', '净额', '账期'], [[
|
||||
$result['status'],
|
||||
$result['row_count'],
|
||||
$result['excluded_rows'],
|
||||
$result['store_count'],
|
||||
$result['bill_item_count'],
|
||||
$result['income_total'],
|
||||
$result['expense_total'],
|
||||
$result['net_total'],
|
||||
$result['period_start'] . ' 至 ' . $result['period_end'],
|
||||
]]);
|
||||
$this->line('入账类型:' . collect($result['entry_type_counts'])
|
||||
->map(fn (int $count, string $type): string => "{$type} {$count}")
|
||||
->implode(';'));
|
||||
$this->line('SHA-256: ' . $result['source_sha256']);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,7 @@ class SyncJushuitanCostPriceHistory extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-jushuitan-cost-price-history
|
||||
{--full : 强制启动或继续首次全量同步}
|
||||
{--page-size= : 聚水潭接口每页读取条数,最大 2000}
|
||||
{--page-size= : 聚水潭接口每页读取条数,最大 50}
|
||||
{--max-pages= : 本轮最多读取页数,0 表示直到完成}';
|
||||
|
||||
protected $description = '按时间窗和页码同步聚水潭商品操作日志中的采购价变更到共享业务镜像';
|
||||
@ -35,13 +35,17 @@ class SyncJushuitanCostPriceHistory extends Command
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('聚水潭历史成本价镜像同步失败。', ['exception' => $exception::class]);
|
||||
$this->error('聚水潭成本价历史同步失败,已保留上一版共享数据。');
|
||||
$this->error(match ($exception->getMessage()) {
|
||||
'jushuitan_classic_api_credentials_missing' => '聚水潭经典开放接口凭据未配置,请配置 partnerid、partnerkey 和 token。',
|
||||
'jushuitan_classic_api_partner_id_invalid' => '聚水潭经典开放接口 partnerid 无效,请核对同一应用的 partnerid、partnerkey 和 token。',
|
||||
default => '聚水潭成本价历史同步失败,已保留上一版共享数据。',
|
||||
});
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($result['status'] === 'requires_full_sync') {
|
||||
$this->warn('聚水潭成本价历史尚未完成首次全量同步,请在凭据和备份确认后显式使用 --full。');
|
||||
$this->warn('聚水潭成本价历史尚未完成首次全量同步,请配置经典开放接口凭据后显式使用 --full。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ class SyncProductPurchasePriceMirror extends Command
|
||||
{--batch= : 每批最多读取行数}
|
||||
{--max-batches= : 本轮最多批次数,0 表示直到完成}';
|
||||
|
||||
protected $description = '同步采购单管理的普通商品单价,并保留可断点的共享业务镜像';
|
||||
protected $description = '同步采购单管理明细和普通商品单价,并保留可断点的共享业务镜像';
|
||||
|
||||
public function handle(
|
||||
ProductPurchasePriceMirrorRepository $mirror,
|
||||
|
||||
374
app/Console/Commands/SyncStoreReceiptOrderMatches.php
Normal file
374
app/Console/Commands/SyncStoreReceiptOrderMatches.php
Normal file
@ -0,0 +1,374 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 业绩明细 → 店铺收款匹配订单明细。
|
||||
*
|
||||
* 以「店铺收款账户账单」为基数(LEFT JOIN 语义,账单每笔收款都保留),
|
||||
* 按「店铺 + 订单号」关联订单侧商品金额,输出严格遵循「一个店铺、一个订单号、一个商品金额」。
|
||||
*
|
||||
* 口径(已与业务确认):
|
||||
* - 账单侧:指定账单项目集合,按「店铺 + 清洗后订单号」汇总收入金额与收款笔数
|
||||
* - 订单侧:销售全量按「店铺 + 线上订单号」先聚合求和(一单多品/多子订单在此合并),金额取 goods_count > 0 的有效行
|
||||
* - 单号清洗:去除日期前缀(如 260630-)、空格、横杠后按核心号匹配
|
||||
* - 店铺:账单用「线上真实店铺」优先,订单侧用销售全量店铺名,统一把下划线归一为横杠
|
||||
* - 同一店铺同一订单号多笔收款 → 标注「一单多笔」,商品金额只挂一次
|
||||
*/
|
||||
class SyncStoreReceiptOrderMatches extends Command
|
||||
{
|
||||
protected $signature = 'store-receipt:order-match:sync
|
||||
{month : 账单月份,格式 YYYY-MM}
|
||||
{--platform=taobao-alipay : 收款账户,目前支持 taobao-alipay}
|
||||
{--chunk=1200 : 单次向上游查询的订单号数量}
|
||||
{--limit=0 : 仅处理前 N 笔账单(调试用,0 表示全量)}';
|
||||
|
||||
protected $description = '按店铺收款账户账单匹配订单侧商品金额,生成「业绩明细」核对镜像';
|
||||
|
||||
/** 收款账户 → 账单镜像表 */
|
||||
private const BILL_TABLES = [
|
||||
'taobao-alipay' => 'taobao_alipay_bill_rows',
|
||||
'taobao-wechat' => 'taobao_wechat_bill_rows',
|
||||
'pinduoduo' => 'pdd_settlement_bill_rows',
|
||||
];
|
||||
|
||||
/** 账单项目:计入收款的账单类型 */
|
||||
private const BILL_ITEMS = [
|
||||
'交易收款',
|
||||
'1688增值服务-诚e赊订单红包',
|
||||
'1688增值服务-诚e赊买家还款',
|
||||
'1688增值服务先采后付担保付款',
|
||||
'1688增值服务先采后付买家还款',
|
||||
'1688增值服务先采后付收款',
|
||||
'交易分账_货款',
|
||||
'其它交易收款',
|
||||
'先采后付账单付款',
|
||||
'百补超级版消费者实付款',
|
||||
'店铺过户',
|
||||
];
|
||||
|
||||
private const TARGET_TABLE = 'store_receipt_order_match_rows';
|
||||
|
||||
private const STATUS_MATCHED = '已匹配';
|
||||
|
||||
private const STATUS_UNMATCHED = '未匹配';
|
||||
|
||||
private const REMARK_EMPTY_NO = '空单号';
|
||||
|
||||
private const REMARK_BAD_FORMAT = '格式不一致';
|
||||
|
||||
private const REMARK_NO_ORDER = '订单表无此单';
|
||||
|
||||
private const REMARK_NO_AMOUNT = '订单无商品金额';
|
||||
|
||||
private const REMARK_SHOP_DIFF = '店铺不一致';
|
||||
|
||||
private const REMARK_DUP_ORDER = '订单侧同号多条';
|
||||
|
||||
private const REMARK_MULTI_PAY = '一单多笔';
|
||||
|
||||
public function handle(UpstreamReadOnlyQueryFactory $factory): int
|
||||
{
|
||||
$month = trim((string) $this->argument('month'));
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
|
||||
throw new InvalidArgumentException('月份格式应为 YYYY-MM,例如 2026-08。');
|
||||
}
|
||||
|
||||
$platform = (string) $this->option('platform');
|
||||
if (! isset(self::BILL_TABLES[$platform])) {
|
||||
throw new InvalidArgumentException('暂不支持的收款账户:' . $platform);
|
||||
}
|
||||
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$billTable = self::BILL_TABLES[$platform];
|
||||
$chunkSize = max(50, (int) $this->option('chunk'));
|
||||
$limit = max(0, (int) $this->option('limit'));
|
||||
|
||||
$start = Carbon::createFromFormat('Y-m-d', $month . '-01')->startOfDay();
|
||||
$end = $start->copy()->addMonthNoOverflow();
|
||||
$reportMonth = $start->toDateString();
|
||||
|
||||
$this->info(sprintf('账期 %s(%s ~ %s),账单来源 %s', $month, $start->toDateString(), $end->copy()->subDay()->toDateString(), $billTable));
|
||||
|
||||
$bills = DB::connection($connection)->table($billTable)
|
||||
->selectRaw('store_name, online_real_store, online_order_no, occurred_at, income_amount')
|
||||
->whereIn('bill_item', self::BILL_ITEMS)
|
||||
->where('occurred_at', '>=', $start->toDateTimeString())
|
||||
->where('occurred_at', '<', $end->toDateTimeString())
|
||||
->orderBy('occurred_at')
|
||||
->get();
|
||||
|
||||
if ($limit > 0) {
|
||||
$bills = $bills->take($limit);
|
||||
}
|
||||
|
||||
if ($bills->isEmpty()) {
|
||||
$this->warn('该账期没有符合条件的账单记录,未写入任何数据。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @var array<string,array<string,mixed>> $groups 账单侧按「店铺 + 订单号」聚合 */
|
||||
$groups = [];
|
||||
$paymentTotal = 0;
|
||||
$incomeTotal = 0.0;
|
||||
foreach ($bills as $bill) {
|
||||
$rawNo = trim((string) ($bill->online_order_no ?? ''));
|
||||
$core = self::cleanOrderNo($rawNo);
|
||||
$shopKey = self::normalizeShop((string) (($bill->online_real_store ?? '') !== '' ? $bill->online_real_store : $bill->store_name));
|
||||
$key = $shopKey . '|' . $core;
|
||||
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'store_name' => (string) ($bill->store_name ?? ''),
|
||||
'shop_key' => $shopKey,
|
||||
'online_order_no' => $rawNo,
|
||||
'order_core_no' => $core,
|
||||
'occurred_date' => substr((string) $bill->occurred_at, 0, 10),
|
||||
'payment_count' => 0,
|
||||
'income_amount' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
$groups[$key]['payment_count']++;
|
||||
$groups[$key]['income_amount'] += (float) $bill->income_amount;
|
||||
$paymentTotal++;
|
||||
$incomeTotal += (float) $bill->income_amount;
|
||||
}
|
||||
|
||||
$this->info('账单收款笔数:' . $paymentTotal . ',按「店铺 + 订单号」聚合后:' . count($groups) . ' 行');
|
||||
|
||||
$cores = [];
|
||||
foreach ($groups as $group) {
|
||||
if ($group['order_core_no'] !== '') {
|
||||
$cores[$group['order_core_no']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$upstream = $factory->forSource('operations_replica');
|
||||
$dwsFrom = $start->copy()->subDays(150)->toDateString();
|
||||
$dwsTo = $end->copy()->addDays(7)->toDateString();
|
||||
|
||||
/** @var array<string,float> $orderAmount 订单侧按「店铺 + 核心单号」聚合的商品金额(仅 goods_count>0 的有效行) */
|
||||
$orderAmount = [];
|
||||
/** @var array<string,true> $orderSeen 订单侧出现过的「店铺 + 核心单号」(含 0 元行) */
|
||||
$orderSeen = [];
|
||||
/** @var array<string,array<string,true>> $coresByCore 核心单号 → 出现过的店铺集合 */
|
||||
$coresByCore = [];
|
||||
/** @var array<string,true> $coreSeen 订单侧出现过的核心单号(含店铺名为空的行) */
|
||||
$coreSeen = [];
|
||||
$chunks = array_chunk(array_keys($cores), $chunkSize);
|
||||
$totalChunks = count($chunks);
|
||||
|
||||
foreach ($chunks as $index => $chunk) {
|
||||
$in = implode(',', array_map(static fn (string $no): string => "'" . str_replace("'", "''", $no) . "'", $chunk));
|
||||
|
||||
try {
|
||||
$rows = $upstream->select(
|
||||
"SELECT sourceno, shop_name, sales_amount, goods_count FROM rpt.dws_sn_sales_full_info_di
|
||||
WHERE sourceno IN ({$in}) AND stat_date >= '{$dwsFrom}' AND stat_date < '{$dwsTo}'"
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('订单侧查询失败(第 ' . ($index + 1) . ' 批):' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$core = self::cleanOrderNo((string) $row['sourceno']);
|
||||
if ($core === '') {
|
||||
continue;
|
||||
}
|
||||
$shopKey = self::normalizeShop((string) ($row['shop_name'] ?? ''));
|
||||
$key = $shopKey . '|' . $core;
|
||||
$orderSeen[$key] = true;
|
||||
$coreSeen[$core] = true;
|
||||
if ($shopKey !== '') {
|
||||
$coresByCore[$core][$shopKey] = true;
|
||||
}
|
||||
if ((int) ($row['goods_count'] ?? 0) <= 0) {
|
||||
// 退款/冲减/0 元赠品行不计入商品金额,差额列另行体现。
|
||||
continue;
|
||||
}
|
||||
$orderAmount[$key] = ($orderAmount[$key] ?? 0.0) + (float) $row['sales_amount'];
|
||||
}
|
||||
|
||||
if (($index + 1) % 10 === 0 || $index + 1 === $totalChunks) {
|
||||
$this->info(sprintf('订单侧进度 %d/%d 批', $index + 1, $totalChunks));
|
||||
}
|
||||
}
|
||||
|
||||
DB::connection($connection)->table(self::TARGET_TABLE)
|
||||
->where('report_month', $reportMonth)
|
||||
->delete();
|
||||
|
||||
$target = DB::connection($connection)->table(self::TARGET_TABLE);
|
||||
$now = now();
|
||||
$buffer = [];
|
||||
$imported = 0;
|
||||
$stats = ['已匹配' => 0, '未匹配' => 0, '一单多笔' => 0];
|
||||
$remarked = [];
|
||||
$goodsTotal = 0.0;
|
||||
$differenceTotal = 0.0;
|
||||
|
||||
foreach ($groups as $group) {
|
||||
$core = (string) $group['order_core_no'];
|
||||
$income = round((float) $group['income_amount'], 2);
|
||||
$remark = '';
|
||||
$productAmount = null;
|
||||
$status = self::STATUS_UNMATCHED;
|
||||
|
||||
if ($core === '') {
|
||||
$remark = self::REMARK_EMPTY_NO;
|
||||
} elseif (preg_match('/^\d{8,}$/', $core) !== 1) {
|
||||
$remark = self::REMARK_BAD_FORMAT;
|
||||
} else {
|
||||
$exactKey = $group['shop_key'] . '|' . $core;
|
||||
if (isset($orderSeen[$exactKey])) {
|
||||
if (isset($orderAmount[$exactKey])) {
|
||||
$status = self::STATUS_MATCHED;
|
||||
$productAmount = round((float) $orderAmount[$exactKey], 2);
|
||||
if (count($coresByCore[$core] ?? []) > 1) {
|
||||
$remark = self::REMARK_DUP_ORDER;
|
||||
}
|
||||
} else {
|
||||
// 订单侧有记录,但全是 0 元/退款行:没有商品金额可比对。
|
||||
$remark = self::REMARK_NO_AMOUNT;
|
||||
}
|
||||
} elseif (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) === 1) {
|
||||
// 单号一致但店铺名称对不上:仍然带上金额,标注「店铺不一致」便于复核。
|
||||
$onlyShop = array_key_first($coresByCore[$core]);
|
||||
$otherKey = $onlyShop . '|' . $core;
|
||||
if (isset($orderAmount[$otherKey])) {
|
||||
$status = self::STATUS_MATCHED;
|
||||
$productAmount = round((float) $orderAmount[$otherKey], 2);
|
||||
$remark = self::REMARK_SHOP_DIFF;
|
||||
} else {
|
||||
$remark = self::REMARK_NO_AMOUNT;
|
||||
}
|
||||
} elseif (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) > 1) {
|
||||
$sum = 0.0;
|
||||
$hasAmount = false;
|
||||
foreach (array_keys($coresByCore[$core]) as $shopKey) {
|
||||
$key2 = $shopKey . '|' . $core;
|
||||
if (isset($orderAmount[$key2])) {
|
||||
$sum += (float) $orderAmount[$key2];
|
||||
$hasAmount = true;
|
||||
}
|
||||
}
|
||||
if ($hasAmount) {
|
||||
$status = self::STATUS_MATCHED;
|
||||
$productAmount = round($sum, 2);
|
||||
$remark = self::REMARK_DUP_ORDER;
|
||||
} else {
|
||||
$remark = self::REMARK_NO_AMOUNT;
|
||||
}
|
||||
} elseif (isset($coreSeen[$core])) {
|
||||
// 订单侧只有店铺名为空的记录:按订单号兜底匹配。
|
||||
$blankKey = '|' . $core;
|
||||
if (isset($orderAmount[$blankKey])) {
|
||||
$status = self::STATUS_MATCHED;
|
||||
$productAmount = round((float) $orderAmount[$blankKey], 2);
|
||||
} else {
|
||||
$remark = self::REMARK_NO_AMOUNT;
|
||||
}
|
||||
} else {
|
||||
$remark = self::REMARK_NO_ORDER;
|
||||
}
|
||||
}
|
||||
|
||||
if ($status === self::STATUS_MATCHED && (int) $group['payment_count'] > 1 && $remark === '') {
|
||||
$remark = self::REMARK_MULTI_PAY;
|
||||
}
|
||||
|
||||
$difference = $productAmount === null ? null : round($income - $productAmount, 2);
|
||||
if ($productAmount !== null) {
|
||||
$goodsTotal += $productAmount;
|
||||
$differenceTotal += (float) $difference;
|
||||
}
|
||||
|
||||
$stats[$status] = ($stats[$status] ?? 0) + 1;
|
||||
if ((int) $group['payment_count'] > 1) {
|
||||
$stats['一单多笔']++;
|
||||
}
|
||||
if ($remark !== '') {
|
||||
$remarked[$remark] = ($remarked[$remark] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$buffer[] = [
|
||||
'report_month' => $reportMonth,
|
||||
'row_key' => md5($reportMonth . '|' . $group['shop_key'] . '|' . $core),
|
||||
'occurred_date' => $group['occurred_date'],
|
||||
'store_name' => $group['store_name'],
|
||||
'online_order_no' => $group['online_order_no'] !== '' ? $group['online_order_no'] : null,
|
||||
'order_core_no' => $core !== '' ? $core : null,
|
||||
'payment_count' => (int) $group['payment_count'],
|
||||
'income_amount' => $income,
|
||||
'product_amount' => $productAmount,
|
||||
'difference' => $difference,
|
||||
'match_status' => $status,
|
||||
'remark' => $remark !== '' ? $remark : null,
|
||||
'imported_at' => $now,
|
||||
];
|
||||
|
||||
if (count($buffer) >= 500) {
|
||||
$target->insertOrIgnore($buffer);
|
||||
$imported += count($buffer);
|
||||
$buffer = [];
|
||||
}
|
||||
}
|
||||
|
||||
if ($buffer !== []) {
|
||||
$target->insertOrIgnore($buffer);
|
||||
$imported += count($buffer);
|
||||
}
|
||||
|
||||
$this->info('写入完成,共 ' . $imported . ' 行(账单收款 ' . $paymentTotal . ' 笔)。');
|
||||
foreach ($stats as $status => $count) {
|
||||
$this->line(' ' . $status . ':' . $count);
|
||||
}
|
||||
foreach ($remarked as $remark => $count) {
|
||||
$this->line(' 标注 ' . $remark . ':' . $count);
|
||||
}
|
||||
$this->line(' 账单收入合计:' . number_format($incomeTotal, 2));
|
||||
$this->line(' 订单侧商品金额合计:' . number_format($goodsTotal, 2));
|
||||
$this->line(' 差额合计:' . number_format($differenceTotal, 2));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** 单号清洗:去掉日期前缀(如 260630-)、空格、横杠,得到核心号。 */
|
||||
public static function cleanOrderNo(string $value): string
|
||||
{
|
||||
$value = str_replace("\u{3000}", ' ', trim($value));
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 日期前缀形如「260630-」:只有明确带 6 位数字 + 横杠时才去除,
|
||||
// 避免误伤本身就是纯数字的订单号(如 5126322747312081812)。
|
||||
if (preg_match('/^\d{6}\s*[-\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', $value) === 1) {
|
||||
$value = preg_replace('/^\d{6}\s*[-\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', '', $value) ?? $value;
|
||||
}
|
||||
|
||||
$value = preg_replace('/[\s\-\x{FF0D}\x{2014}\x{2013}\x{2010}]+/u', '', $value) ?? '';
|
||||
|
||||
return strtoupper($value);
|
||||
}
|
||||
|
||||
/** 店铺名归一:下划线视为横杠,去掉空白。 */
|
||||
public static function normalizeShop(string $value): string
|
||||
{
|
||||
$value = str_replace(["\u{3000}", '_'], [' ', '-'], trim($value));
|
||||
$value = preg_replace('/\s+/', '', $value) ?? '';
|
||||
|
||||
return strtoupper($value);
|
||||
}
|
||||
}
|
||||
@ -38,9 +38,12 @@ class SyncStoreReportMirror extends Command
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'店铺报表镜像:生命周期 %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'],
|
||||
$result['period_changes']['checked'],
|
||||
$result['period_changes']['created'],
|
||||
$result['bills']['status'],
|
||||
$result['bills']['source_rows'],
|
||||
$result['daily']['status'],
|
||||
|
||||
135
app/Console/Commands/SyncStoreViolations.php
Normal file
135
app/Console/Commands/SyncStoreViolations.php
Normal file
@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\StoreViolationMapper;
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
|
||||
use Modules\EcommerceFinance\Services\UpstreamOdbcConnection;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 店铺违规信息预警:从经营分析副本库 rpt.crawl_shop_violation 同步到统一表。
|
||||
*
|
||||
* 上游只读;按 id 游标分批读取,幂等写入 store_violation_records。
|
||||
* 本地补充字段(扣分、处罚日期、处理状态)不会被覆盖。
|
||||
*/
|
||||
class SyncStoreViolations extends Command
|
||||
{
|
||||
protected $signature = 'store-violations:sync
|
||||
{--platform= : 只同步指定平台(taobao/alibaba/jd/jingxi/pinduoduo/douyin)}
|
||||
{--batch=1000 : 每批读取行数}';
|
||||
|
||||
protected $description = '同步店铺违规信息(operations_replica.rpt.crawl_shop_violation → store_violation_records)';
|
||||
|
||||
public function handle(StoreViolationMapper $mapper, StoreViolationRecordRepository $repository): int
|
||||
{
|
||||
if (! $repository->available()) {
|
||||
$this->error('统一表 store_violation_records 尚未建立,请先执行数据库迁移。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$platform = trim((string) $this->option('platform'));
|
||||
$platforms = (array) config('store_violations.platforms', []);
|
||||
if ($platform !== '' && ! array_key_exists($platform, $platforms)) {
|
||||
$this->error('未知平台:' . $platform);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$sourceValues = [];
|
||||
foreach ($platforms as $slug => $definition) {
|
||||
if ($platform !== '' && $slug !== $platform) {
|
||||
continue;
|
||||
}
|
||||
foreach ($definition['source_values'] ?? [] as $value) {
|
||||
$sourceValues[] = (string) $value;
|
||||
}
|
||||
}
|
||||
$sourceValues = array_values(array_unique($sourceValues));
|
||||
|
||||
$batchSize = max(min((int) $this->option('batch'), 5000), 100);
|
||||
|
||||
try {
|
||||
$pdo = UpstreamOdbcConnection::create('operations_replica');
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$repository->recordSyncState('failed', $repository->totalRows(), 'connect_failed');
|
||||
$this->error('无法连接经营分析副本库:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$lastId = 0;
|
||||
$upserted = 0;
|
||||
$skipped = 0;
|
||||
$read = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
$sql = 'SELECT TOP ' . $batchSize
|
||||
. ' id, shop_name, violation_no, violation_date, deadline, violation_content,'
|
||||
. ' status, window_name, platform, crawl_date'
|
||||
. ' FROM rpt.crawl_shop_violation WHERE id > ?';
|
||||
$params = [$lastId];
|
||||
|
||||
if ($sourceValues !== []) {
|
||||
$sql .= ' AND platform IN (' . implode(', ', array_fill(0, count($sourceValues), '?')) . ')';
|
||||
$params = array_merge($params, $sourceValues);
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY id ASC';
|
||||
|
||||
$statement = $pdo->prepare($sql);
|
||||
$statement->execute($params);
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
$statement->closeCursor();
|
||||
|
||||
if ($rows === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
$payload = [];
|
||||
foreach ($rows as $row) {
|
||||
$read++;
|
||||
$lastId = max($lastId, (int) ($row['id'] ?? 0));
|
||||
$mapped = $mapper->mapCrawlRow($row);
|
||||
if ($mapped === null || $mapped['violation_no'] === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload[] = $mapped + [
|
||||
'synced_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
$upserted += $repository->upsertRows($payload, StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$repository->recordSyncState('failed', $repository->totalRows(), 'sync_failed');
|
||||
$this->error('同步失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$total = $repository->totalRows();
|
||||
$repository->recordSyncState('succeeded', $total);
|
||||
|
||||
$this->info(sprintf(
|
||||
'同步完成:读取 %d 行,写入/更新 %d 行,跳过 %d 行,统一表共 %d 行。',
|
||||
$read,
|
||||
$upserted,
|
||||
$skipped,
|
||||
$total,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -10,10 +10,10 @@ use Throwable;
|
||||
class SyncThirdPartyWarehouseStorage extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-third-party-warehouse-storage
|
||||
{--full : 启动或继续三方仓流水与库存的全量校准}
|
||||
{--full : 启动或继续聚水潭全仓流水与库存的全量校准}
|
||||
{--max-batches= : 本轮最多批次数,0 表示直到完成}';
|
||||
|
||||
protected $description = '同步南昌樵舍三方仓按商品进销存数据到共享业务镜像';
|
||||
protected $description = '同步聚水潭全部仓库按商品进销存数据到共享业务镜像';
|
||||
|
||||
public function handle(ThirdPartyWarehouseStorageSynchronizer $synchronizer): int
|
||||
{
|
||||
@ -27,29 +27,29 @@ class SyncThirdPartyWarehouseStorage extends Command
|
||||
$this->option('max-batches') === null ? null : (int) $this->option('max-batches'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('三方仓仓储数据共享镜像同步失败。', [
|
||||
Log::error('聚水潭进销存数据共享镜像同步失败。', [
|
||||
'dataset' => ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY,
|
||||
'exception' => $exception::class,
|
||||
]);
|
||||
$this->error('三方仓仓储数据同步失败,页面继续读取上一版完整数据。');
|
||||
$this->error('聚水潭进销存数据同步失败,页面继续读取上一版完整数据。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($result['status'] === 'requires_full_sync') {
|
||||
$this->warn('三方仓仓储数据尚未完成首次全量同步,请显式使用 --full。');
|
||||
$this->warn('聚水潭进销存数据尚未完成首次全量同步,请显式使用 --full。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($result['status'] === 'already_running') {
|
||||
$this->line('三方仓仓储数据已有同步任务运行,本次不重复启动。');
|
||||
$this->line('聚水潭进销存数据已有同步任务运行,本次不重复启动。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'三方仓仓储数据%s:%s,本轮 %d 批;流水 %d 条、明细 %d 条、库存 %d 条。',
|
||||
'聚水潭进销存数据%s:%s,本轮 %d 批;流水 %d 条、明细 %d 条、库存 %d 条。',
|
||||
$result['mode'] === 'full' ? '全量校准' : '增量同步',
|
||||
$result['status'] === 'succeeded' ? '完成' : '进行中',
|
||||
$result['batches'],
|
||||
|
||||
85
app/Console/Commands/WarmReportCaches.php
Normal file
85
app/Console/Commands/WarmReportCaches.php
Normal file
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Repositories\SmallPaymentWorkOrderMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
|
||||
use Throwable;
|
||||
|
||||
class WarmReportCaches extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:warm-report-caches';
|
||||
|
||||
protected $description = '预热默认进销存、淘系支付宝与小额打款报表,避免镜像切换后的首批请求重复扫表';
|
||||
|
||||
public function handle(
|
||||
ThirdPartyWarehouseStorageMirrorRepository $inventory,
|
||||
StoreReceivingAlipayBillMirrorRepository $alipay,
|
||||
SmallPaymentWorkOrderMirrorRepository $smallPayments,
|
||||
): int {
|
||||
$failed = false;
|
||||
|
||||
try {
|
||||
$month = now()->startOfMonth()->subMonth()->format('Y-m');
|
||||
$result = $inventory->paginateMonthlyInventoryReport([
|
||||
'month' => $month,
|
||||
'product_code' => '',
|
||||
'warehouse_provider' => '',
|
||||
'category' => '',
|
||||
], 1, 25);
|
||||
$this->info(sprintf('进销存 %s 首页已预热,共 %d 行。', $month, $result['total']));
|
||||
} catch (Throwable $exception) {
|
||||
$failed = true;
|
||||
Log::error('默认进销存报表缓存预热失败。', ['exception' => $exception::class]);
|
||||
$this->error('默认进销存报表缓存预热失败。');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $alipay->paginate([], 'occurred_at', 'desc', 1, 10);
|
||||
$this->info(sprintf('淘系支付宝首页已预热,共 %d 行。', $result['total']));
|
||||
$options = $alipay->filterOptionsForChannel(
|
||||
'alipayCSV',
|
||||
StoreReceivingAlipayBillMirrorRepository::DATASET,
|
||||
true,
|
||||
);
|
||||
$this->info(sprintf(
|
||||
'淘系支付宝筛选选项已预热,共 %d 个店铺、%d 个账单项目。',
|
||||
count($options['shops']),
|
||||
count($options['bill_items']),
|
||||
));
|
||||
} catch (Throwable $exception) {
|
||||
$failed = true;
|
||||
Log::error('淘系支付宝报表缓存预热失败。', ['exception' => $exception::class]);
|
||||
$this->error('淘系支付宝报表缓存预热失败。');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $smallPayments->paginate([
|
||||
'from' => now()->subDays(29)->format('Y-m-d') . 'T00:00:00',
|
||||
'to' => now()->format('Y-m-d') . 'T23:59:59',
|
||||
'work_order_no' => '',
|
||||
'shop_name' => '',
|
||||
'transaction_no' => '',
|
||||
'online_order_no' => '',
|
||||
'status' => '',
|
||||
'payment_reason' => '',
|
||||
], 1, 25);
|
||||
$options = $smallPayments->filterOptions();
|
||||
$this->info(sprintf(
|
||||
'小额打款首页已预热,共 %d 行、%d 个店铺、%d 个打款原因。',
|
||||
$result['total'],
|
||||
count($options['shops']),
|
||||
count($options['payment_reasons']),
|
||||
));
|
||||
} catch (Throwable $exception) {
|
||||
$failed = true;
|
||||
Log::error('小额打款报表缓存预热失败。', ['exception' => $exception::class]);
|
||||
$this->error('小额打款报表缓存预热失败。');
|
||||
}
|
||||
|
||||
return $failed ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -35,11 +35,27 @@ class Kernel extends ConsoleKernel
|
||||
$schedule->command('storage-temp:clear')->dailyAt('17:00');
|
||||
$schedule->command('model:prune')->dailyAt('17:00');
|
||||
|
||||
// 线上代发价格:每天凌晨 00:00 全量/增量同步一次钉钉线上代发业务报销流程。
|
||||
// 线上代发价格:每天凌晨 05:00 刷新钉钉审批、采购单和订单关联镜像。
|
||||
// 生产环境由 business-mirror:sync-tables 的 cron 持续增量覆盖,此处保证
|
||||
// 一个固定的每日零点刷新入口;命令本身在非刷新环境会安全跳过。
|
||||
// 一个固定的每日刷新入口;命令本身在非刷新环境会安全跳过。
|
||||
$schedule->command('business-mirror:sync-ding-online-daifa-price')
|
||||
->dailyAt('00:00')
|
||||
->dailyAt('05:00')
|
||||
->withoutOverlapping(60)
|
||||
->runInBackground();
|
||||
|
||||
$schedule->command('business-mirror:sync-product-purchase-prices')
|
||||
->dailyAt('05:00')
|
||||
->withoutOverlapping(60)
|
||||
->runInBackground();
|
||||
|
||||
$schedule->command('business-mirror:sync-tables operations_replica.rpt.jst_Orders')
|
||||
->dailyAt('05:00')
|
||||
->withoutOverlapping(60)
|
||||
->runInBackground();
|
||||
|
||||
// 店铺违规信息预警:每天同步副本库抓取结果到统一表(本地补充字段不覆盖)。
|
||||
$schedule->command('store-violations:sync')
|
||||
->dailyAt('05:30')
|
||||
->withoutOverlapping(60)
|
||||
->runInBackground();
|
||||
|
||||
@ -55,7 +71,7 @@ class Kernel extends ConsoleKernel
|
||||
->runInBackground();
|
||||
}
|
||||
|
||||
// 聚水潭发货数据增量同步:每 10 分钟按 modified 水位拉销售出库单。
|
||||
// 聚水潭发货数据每日重拉最近三天,覆盖副本库迟到更新。
|
||||
// 默认关闭(JST_SYNC_ENABLED=false);填好凭证并完成迁移后再打开。
|
||||
if ((bool) env('JST_SYNC_ENABLED', false)) {
|
||||
$schedule->command('jushuitan:sync-shipping --days=3')
|
||||
|
||||
49
app/Exports/ThirdPartyWarehouseStorageDetailExport.php
Normal file
49
app/Exports/ThirdPartyWarehouseStorageDetailExport.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\FromArray;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
|
||||
class ThirdPartyWarehouseStorageDetailExport implements FromArray, WithHeadings, WithTitle
|
||||
{
|
||||
/** @param list<array<string,mixed>> $rows */
|
||||
public function __construct(private readonly array $rows)
|
||||
{
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return '每日商品仓储费';
|
||||
}
|
||||
|
||||
/** @return array<int,string> */
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'日期', '货主', '商品编码', '商品名称', '供应商', '分类', '单位',
|
||||
'上日结余', '当日进出净额', '当日库存结余', '当天仓储费',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int,array<int,mixed>> */
|
||||
public function array(): array
|
||||
{
|
||||
return array_map(static fn (array $row): array => [
|
||||
$row['date'],
|
||||
$row['owner'],
|
||||
$row['sku_code'],
|
||||
$row['product_name'],
|
||||
$row['supplier'],
|
||||
$row['category'],
|
||||
$row['unit'],
|
||||
(float) $row['opening'],
|
||||
(float) $row['net_movement'],
|
||||
(float) $row['closing'],
|
||||
(float) $row['storage_fee'],
|
||||
], $this->rows);
|
||||
}
|
||||
}
|
||||
@ -11,13 +11,14 @@ use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
|
||||
/**
|
||||
* 三方仓仓储费核算明细导出:汇总 / 进销存数据汇总(每日) / 进销存(逐商品) 三张表。
|
||||
* 三方仓仓储费核算明细导出:汇总 / 进销存数据汇总(每日) / 进销存(逐商品) /
|
||||
* 每日商品仓储费四张表。
|
||||
* 数据来自 ThirdPartyWarehouseStorageFeeCalculator 的核算结果(或存档快照)。
|
||||
*/
|
||||
class ThirdPartyWarehouseStorageSettlementExport implements WithMultipleSheets
|
||||
{
|
||||
/**
|
||||
* @param array{summary:array<string,mixed>,daily:list<array<string,mixed>>,per_sku:list<array<string,mixed>>} $result
|
||||
* @param array{summary:array<string,mixed>,daily:list<array<string,mixed>>,per_sku:list<array<string,mixed>>,daily_sku?:list<array<string,mixed>>} $result
|
||||
*/
|
||||
public function __construct(private readonly array $result)
|
||||
{
|
||||
@ -30,6 +31,7 @@ class ThirdPartyWarehouseStorageSettlementExport implements WithMultipleSheets
|
||||
new StorageSettlementSummarySheet($this->result['summary']),
|
||||
new StorageSettlementDailySheet($this->result['summary'], $this->result['daily']),
|
||||
new StorageSettlementSkuSheet($this->result['per_sku']),
|
||||
new ThirdPartyWarehouseStorageDetailExport($this->result['daily_sku'] ?? []),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -85,6 +85,10 @@ class Login extends Controller
|
||||
$path = route('portal.dashboard', ['company_id' => $company->id]);
|
||||
}
|
||||
|
||||
if (! $request->expectsJson()) {
|
||||
return redirect()->to($path);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => null,
|
||||
'success' => true,
|
||||
@ -98,6 +102,10 @@ class Login extends Controller
|
||||
// Redirect to landing page if is user
|
||||
$url = route($user->landing_page, ['company_id' => $company->id]);
|
||||
|
||||
if (! $request->expectsJson()) {
|
||||
return redirect()->intended($url);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => null,
|
||||
'success' => true,
|
||||
@ -118,6 +126,14 @@ class Login extends Controller
|
||||
*/
|
||||
protected function respondLoginFailed()
|
||||
{
|
||||
$request = app('Illuminate\Http\Request');
|
||||
|
||||
if (! $request->expectsJson()) {
|
||||
return redirect()->back()
|
||||
->withInput($request->except('password'))
|
||||
->withErrors(['email' => trans('auth.failed')]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => null,
|
||||
'success' => false,
|
||||
|
||||
578
app/Http/Controllers/Business/AccountMappingActions.php
Normal file
578
app/Http/Controllers/Business/AccountMappingActions.php
Normal file
@ -0,0 +1,578 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Models\Auth\User;
|
||||
use App\Traits\Companies;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\EcommerceFinance\Services\AccountMappingCoa;
|
||||
use Modules\EcommerceFinance\Services\AccountMappingStore;
|
||||
use Modules\EcommerceFinance\Services\AccountMappingValidator;
|
||||
use Modules\EcommerceFinance\Services\AccountMappingWorkbookImporter;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* 财务规则 · 科目映射表的维护接口。
|
||||
*
|
||||
* 表里存的是「科目对 + 方向基准」,不存最终借贷方向;方向在记账时按账单实际收支代入。
|
||||
* 本控制器只负责:增、改、删、导入追加、导出、模板、校验。
|
||||
*/
|
||||
class AccountMappingActions extends Controller
|
||||
{
|
||||
use Companies;
|
||||
|
||||
/** 操作记录保留并展示的条数 */
|
||||
private const HISTORY_LIMIT = 500;
|
||||
|
||||
private const HISTORY_ACTION_LABELS = [
|
||||
'create' => '新增',
|
||||
'update' => '修改',
|
||||
'delete' => '删除',
|
||||
'import' => '导入',
|
||||
'reset' => '重置',
|
||||
];
|
||||
|
||||
private const HISTORY_FIELD_LABELS = [
|
||||
'platform' => '平台',
|
||||
'item' => '账单项目',
|
||||
'memo' => '财务备注',
|
||||
'invoice' => '票据口径',
|
||||
'category' => '大类',
|
||||
'dAcct' => '借方科目',
|
||||
'dSub' => '借方二级',
|
||||
'cAcct' => '贷方科目',
|
||||
'cSub' => '贷方二级',
|
||||
'basis' => '方向基准',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly AccountMappingStore $store,
|
||||
private readonly AccountMappingValidator $validator,
|
||||
private readonly AccountMappingCoa $coa,
|
||||
private readonly AccountMappingWorkbookImporter $importer,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
}
|
||||
|
||||
/** 全量规则 + 校验结果 + 筛选项 */
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return response()->json($this->payload());
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$values = $this->payloadValues($request);
|
||||
if (trim((string) ($values['platform'] ?? '')) === '' || trim((string) ($values['item'] ?? '')) === '') {
|
||||
throw ValidationException::withMessages(['platform' => '平台和账单项目不能为空。']);
|
||||
}
|
||||
|
||||
$this->store->create($values, $this->userId());
|
||||
|
||||
return response()->json(['message' => '已新增 1 条规则。'] + $this->payload());
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
if ($this->store->find($id) === null) {
|
||||
throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']);
|
||||
}
|
||||
|
||||
$this->store->update($id, $this->payloadValues($request), $this->userId());
|
||||
|
||||
return response()->json(['message' => '已保存。'] + $this->payload());
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
if (! $this->store->destroy($id, $this->userId())) {
|
||||
throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']);
|
||||
}
|
||||
|
||||
return response()->json(['message' => '已删除 1 条规则。'] + $this->payload());
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作记录:最近 500 条(新增 / 修改 / 删除 / 导入 / 重置)。
|
||||
*
|
||||
* 每条含改动前、改动后的完整规则内容,所以能查出「谁在什么时候把哪个字段从什么改成了什么」,
|
||||
* 也能据此还原被删掉的行。操作人只返回名称,不返回账号等敏感字段。
|
||||
*/
|
||||
public function history(): JsonResponse
|
||||
{
|
||||
$events = $this->historyEvents();
|
||||
|
||||
return response()->json([
|
||||
'events' => $events,
|
||||
'total' => count($events),
|
||||
'limit' => self::HISTORY_LIMIT,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作记录导出为 CSV,筛选口径与页面上的「操作记录」一致。
|
||||
*/
|
||||
public function historyExport(Request $request): StreamedResponse
|
||||
{
|
||||
$action = trim((string) $request->query('action', ''));
|
||||
$keyword = trim((string) $request->query('keyword', ''));
|
||||
|
||||
$rows = array_values(array_filter(
|
||||
$this->historyEvents(),
|
||||
fn (array $row): bool => $this->historyMatches($row, $action, $keyword)
|
||||
));
|
||||
|
||||
return response()->streamDownload(function () use ($rows): void {
|
||||
$handle = fopen('php://output', 'w');
|
||||
fwrite($handle, "\xEF\xBB\xBF");
|
||||
fputcsv($handle, ['时间', '操作', '平台', '账单项目', '财务备注', '变更内容', '操作人']);
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($handle, [
|
||||
$row['at'], $row['action_label'], $row['platform'], $row['item'], $row['memo'],
|
||||
implode(';', $row['changes']), $row['actor'],
|
||||
]);
|
||||
}
|
||||
fclose($handle);
|
||||
}, '科目映射表_操作记录.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按操作记录恢复:被删除的规则重新建回来,被改过的规则恢复成改前内容。
|
||||
*
|
||||
* 前端传的是「记录在列表里的位置 + 时间 + 操作 + 规则号」,三者都对得上才动手,
|
||||
* 避免这期间有人新增了记录、位置发生偏移时恢复错行。
|
||||
*/
|
||||
public function historyRestore(Request $request): JsonResponse
|
||||
{
|
||||
$index = (int) $request->input('index', -1);
|
||||
$at = trim((string) $request->input('at', ''));
|
||||
$action = trim((string) $request->input('action', ''));
|
||||
$ruleId = (int) $request->input('id', 0);
|
||||
|
||||
$event = $this->store->events(self::HISTORY_LIMIT)[$index] ?? null;
|
||||
|
||||
if ($event === null
|
||||
|| (string) ($event['at'] ?? '') !== $at
|
||||
|| (string) ($event['action'] ?? '') !== $action
|
||||
|| (int) ($event['id'] ?? 0) !== $ruleId) {
|
||||
throw ValidationException::withMessages(['history' => '操作记录已经变化,请关掉面板重新打开再试。']);
|
||||
}
|
||||
|
||||
$before = is_array($event['before'] ?? null) ? $event['before'] : null;
|
||||
if ($before === null) {
|
||||
throw ValidationException::withMessages(['history' => '这条记录没有可恢复的内容。']);
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
foreach ($this->store->all() as $rule) {
|
||||
if ($this->store->keyOf($rule) === $this->store->keyOf($before)) {
|
||||
throw ValidationException::withMessages([
|
||||
'history' => '这条规则已经在表里了(记录 #' . (int) $rule['id'] . '),不用恢复。',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$restored = $this->store->create($before, $this->userId());
|
||||
|
||||
return response()->json([
|
||||
'message' => '已恢复「' . $restored['platform'] . ' · ' . $restored['item'] . '」(新记录 #' . (int) $restored['id'] . ')。',
|
||||
] + $this->payload());
|
||||
}
|
||||
|
||||
if ($action === 'update') {
|
||||
if ($this->store->find($ruleId) === null) {
|
||||
throw ValidationException::withMessages(['history' => '这条规则已经被删掉了,请用同一条的「删除」记录恢复。']);
|
||||
}
|
||||
|
||||
$this->store->update($ruleId, $before, $this->userId());
|
||||
|
||||
return response()->json([
|
||||
'message' => '已恢复成这次修改前的内容(记录 #' . $ruleId . ')。',
|
||||
] + $this->payload());
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages(['history' => '只有「删除」和「修改」的记录可以恢复。']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入预览:只解析、不落盘。
|
||||
*
|
||||
* 单独一条路由、且不挂 throttle:import —— 因为平台对「导入」限的是 1 次/分钟,
|
||||
* 而页面上「预览 → 确认导入」天然要发两次请求,预览走限流必然 429。
|
||||
*/
|
||||
public function preview(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json($this->previewPayload($this->workbookFile($request)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入落盘。带 ?dry_run=1 时行为同 preview(保留兼容)。
|
||||
*/
|
||||
public function import(Request $request): JsonResponse
|
||||
{
|
||||
$file = $this->workbookFile($request);
|
||||
|
||||
if ($request->boolean('dry_run')) {
|
||||
return response()->json($this->previewPayload($file));
|
||||
}
|
||||
|
||||
$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']} 条。";
|
||||
|
||||
return response()->json(['message' => $message, 'result' => $result] + $this->payload());
|
||||
}
|
||||
|
||||
/**
|
||||
* 取上传的工作簿。
|
||||
*
|
||||
* 这里**故意不用** `mimes:xlsx,xls,csv`:那个规则靠 libmagic 嗅探真实类型,
|
||||
* 而只有两三行的 CSV 会被猜成 `text/plain`、扩展名算成 `txt`,于是合法文件被拒
|
||||
* (实测 2 行 231B 的 csv 报「必须是 xlsx, xls, csv 的文件」)。按扩展名判就稳。
|
||||
*/
|
||||
protected function workbookFile(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'import' => ['required', 'file', 'max:10240'],
|
||||
], [
|
||||
'import.required' => '请先选择要导入的文件。',
|
||||
'import.file' => '请选择要导入的文件。',
|
||||
'import.max' => '文件不能超过 10MB。',
|
||||
]);
|
||||
|
||||
$file = $request->file('import');
|
||||
$extension = mb_strtolower((string) $file->getClientOriginalExtension());
|
||||
|
||||
if (! in_array($extension, ['xlsx', 'xls', 'csv'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '只支持 xlsx、xls、csv 三种格式,当前是「' . $extension . '」。',
|
||||
]);
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Http\UploadedFile $file
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function previewPayload($file): array
|
||||
{
|
||||
$parsed = $this->importer->parse($file);
|
||||
$rows = $parsed['rows'];
|
||||
|
||||
$existing = [];
|
||||
foreach ($this->store->all() as $rule) {
|
||||
$existing[$this->store->keyOf($rule)] = true;
|
||||
}
|
||||
|
||||
$fresh = [];
|
||||
$duplicated = [];
|
||||
foreach ($rows as $row) {
|
||||
$key = $this->store->keyOf($row);
|
||||
if (isset($existing[$key])) {
|
||||
$duplicated[$key] = ($duplicated[$key] ?? 0) + 1;
|
||||
} else {
|
||||
$fresh[$key] = true;
|
||||
$existing[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'dry_run' => true,
|
||||
'sheet' => $parsed['sheet'],
|
||||
'total' => count($rows),
|
||||
'can_create' => count($fresh),
|
||||
'can_overwrite' => count($duplicated),
|
||||
'duplicate_keys' => array_slice(array_keys($duplicated), 0, 20),
|
||||
'sample' => array_slice($rows, 0, 6),
|
||||
];
|
||||
}
|
||||
|
||||
/** 恢复成初始 112 条 */
|
||||
public function reset(): JsonResponse
|
||||
{
|
||||
$total = $this->store->reset($this->userId());
|
||||
|
||||
return response()->json(['message' => "已恢复为初始 {$total} 条规则。"] + $this->payload());
|
||||
}
|
||||
|
||||
/** 账套科目(一级 + 各一级下的明细),供下拉使用 */
|
||||
public function accounts(): JsonResponse
|
||||
{
|
||||
$level1 = $this->coa->level1();
|
||||
$children = [];
|
||||
foreach ($level1 as $account) {
|
||||
$children[$account['code']] = $this->coa->descendants($account['code']);
|
||||
}
|
||||
|
||||
return response()->json(['level1' => $level1, 'children' => $children]);
|
||||
}
|
||||
|
||||
/** 按当前表导出 CSV(Excel 可直接打开) */
|
||||
public function export(): StreamedResponse
|
||||
{
|
||||
$rules = $this->store->all();
|
||||
|
||||
return response()->streamDownload(function () use ($rules): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
if ($csv === false) {
|
||||
throw new RuntimeException('无法创建导出文件。');
|
||||
}
|
||||
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
fputcsv($csv, array_values(AccountMappingStore::LABELS));
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
$row = [];
|
||||
foreach (array_keys(AccountMappingStore::LABELS) as $field) {
|
||||
$value = (string) ($rule[$field] ?? '');
|
||||
// 商品名称关键词留空时导出成「-」,与页面一致(导回来会按空处理)
|
||||
if ($field === 'goods' && trim($value) === '') {
|
||||
$value = '-';
|
||||
}
|
||||
$row[] = $this->csvText($value);
|
||||
}
|
||||
fputcsv($csv, $row);
|
||||
}
|
||||
fclose($csv);
|
||||
}, '科目映射表_' . $this->store->platformNow()->format('Ymd_His') . '.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/** 导入模板(表头 + 两条示例,并且这一份可以直接改完再导回来) */
|
||||
public function template(): StreamedResponse
|
||||
{
|
||||
$samples = [
|
||||
['得物', '平台服务费', '平台服务费', '0.06', '费用', '销售费用', '平台佣金', '其他货币资金', '', '支出基准'],
|
||||
['淘宝', '交易收入', '订单', '-', '收入', '其他货币资金', '', '主营业务收入', '', '收入基准'],
|
||||
];
|
||||
|
||||
return response()->streamDownload(function () use ($samples): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
if ($csv === false) {
|
||||
throw new RuntimeException('无法创建模板文件。');
|
||||
}
|
||||
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
fputcsv($csv, array_values(AccountMappingStore::LABELS));
|
||||
foreach ($samples as $sample) {
|
||||
fputcsv($csv, $sample);
|
||||
}
|
||||
fclose($csv);
|
||||
}, '科目映射表_导入模板.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function payload(): array
|
||||
{
|
||||
$rules = $this->store->all();
|
||||
$validated = $this->validator->validate($rules);
|
||||
|
||||
$rows = [];
|
||||
foreach ($rules as $rule) {
|
||||
$rows[] = $rule + ['issues' => $validated['issues'][(int) $rule['id']] ?? []];
|
||||
}
|
||||
|
||||
return [
|
||||
'rules' => $rows,
|
||||
'summary' => $validated['summary'],
|
||||
'options' => $this->store->options(),
|
||||
'events' => $this->store->events(20),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private function payloadValues(Request $request): array
|
||||
{
|
||||
$input = (array) $request->input('values', $request->all());
|
||||
|
||||
return $this->store->normalize($input);
|
||||
}
|
||||
|
||||
private function userId(): ?int
|
||||
{
|
||||
return user_id() ? (int) user_id() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 原始操作记录 + 页面/CSV 直接可用的字段(变更内容与操作人只在服务端算一次)。
|
||||
*
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function historyEvents(): array
|
||||
{
|
||||
$events = $this->store->events(self::HISTORY_LIMIT);
|
||||
$names = $this->historyUserNames($events);
|
||||
|
||||
$rows = [];
|
||||
foreach ($events as $event) {
|
||||
$before = is_array($event['before'] ?? null) ? $event['before'] : null;
|
||||
$after = is_array($event['after'] ?? null) ? $event['after'] : null;
|
||||
$rule = $before ?? $after ?? [];
|
||||
$action = (string) ($event['action'] ?? '');
|
||||
|
||||
$rows[] = $event + [
|
||||
'action_label' => self::HISTORY_ACTION_LABELS[$action] ?? $action,
|
||||
'platform' => trim((string) ($rule['platform'] ?? '')),
|
||||
'item' => trim((string) ($rule['item'] ?? '')),
|
||||
'memo' => trim((string) ($rule['memo'] ?? '')),
|
||||
'changes' => $this->historyChangeParts($event, $before, $after),
|
||||
'actor' => $this->historyActor($event, $names),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $events
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private function historyUserNames(array $events): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($events as $event) {
|
||||
$userId = (int) ($event['user_id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
$ids[$userId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return User::query()->whereIn('id', array_keys($ids))->pluck('name', 'id')->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $event
|
||||
* @param array<int,string> $names
|
||||
*/
|
||||
private function historyActor(array $event, array $names): string
|
||||
{
|
||||
$userId = (int) ($event['user_id'] ?? 0);
|
||||
|
||||
if ($userId <= 0) {
|
||||
return '脚本 / 系统';
|
||||
}
|
||||
|
||||
return $names[$userId] ?? ('用户 #' . $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 变更内容:修改给出「字段:改前 → 改后」,删除/新增给出科目对摘要,导入/重置给出条数。
|
||||
*
|
||||
* @param array<string,mixed> $event
|
||||
* @param array<string,string>|null $before
|
||||
* @param array<string,string>|null $after
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private function historyChangeParts(array $event, ?array $before, ?array $after): array
|
||||
{
|
||||
$action = (string) ($event['action'] ?? '');
|
||||
|
||||
if ($action === 'update') {
|
||||
$parts = [];
|
||||
foreach (self::HISTORY_FIELD_LABELS as $field => $label) {
|
||||
$old = trim((string) ($before[$field] ?? ''));
|
||||
$new = trim((string) ($after[$field] ?? ''));
|
||||
if ($old === $new) {
|
||||
continue;
|
||||
}
|
||||
$parts[] = $label . ':' . ($old === '' ? '(空)' : $old) . ' → ' . ($new === '' ? '(空)' : $new);
|
||||
}
|
||||
|
||||
return $parts === [] ? ['未识别到字段变化'] : $parts;
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
return ['删除前:' . $this->historyRuleSummary($before)];
|
||||
}
|
||||
|
||||
if ($action === 'create') {
|
||||
return ['新增:' . $this->historyRuleSummary($after)];
|
||||
}
|
||||
|
||||
if ($action === 'import') {
|
||||
$parts = [];
|
||||
foreach (['created' => '新增', 'updated' => '更新', 'skipped' => '跳过'] as $key => $label) {
|
||||
if (isset($event[$key])) {
|
||||
$parts[] = $label . ' ' . (int) $event[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return ['导入:' . ($parts === [] ? '—' : implode(' · ', $parts))];
|
||||
}
|
||||
|
||||
if ($action === 'reset') {
|
||||
return ['重置为初始规则' . (isset($event['total']) ? '(' . (int) $event['total'] . ' 条)' : '')];
|
||||
}
|
||||
|
||||
return [$this->historyRuleSummary($before ?? $after)];
|
||||
}
|
||||
|
||||
/** @param array<string,string>|null $rule */
|
||||
private function historyRuleSummary(?array $rule): string
|
||||
{
|
||||
$rule ??= [];
|
||||
$side = static function (string $acctKey, string $subKey) use ($rule): string {
|
||||
$acct = trim((string) ($rule[$acctKey] ?? ''));
|
||||
$sub = trim((string) ($rule[$subKey] ?? ''));
|
||||
if ($acct === '' && $sub === '') {
|
||||
return '—';
|
||||
}
|
||||
if ($sub === '' || $sub === $acct) {
|
||||
return $acct !== '' ? $acct : $sub;
|
||||
}
|
||||
|
||||
return $acct . ' / ' . $sub;
|
||||
};
|
||||
|
||||
return '借 ' . $side('dAcct', 'dSub') . ' | 贷 ' . $side('cAcct', 'cSub')
|
||||
. ' | ' . (trim((string) ($rule['category'] ?? '')) ?: '—')
|
||||
. ' · ' . (trim((string) ($rule['basis'] ?? '')) ?: '—')
|
||||
. ' · 票据 ' . (trim((string) ($rule['invoice'] ?? '')) ?: '—');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private function historyMatches(array $row, string $action, string $keyword): bool
|
||||
{
|
||||
if ($action !== '' && (string) ($row['action'] ?? '') !== $action) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($keyword === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$haystack = mb_strtolower(implode(' ', [
|
||||
$row['platform'] ?? '',
|
||||
$row['item'] ?? '',
|
||||
$row['memo'] ?? '',
|
||||
$row['actor'] ?? '',
|
||||
implode(' ', (array) ($row['changes'] ?? [])),
|
||||
]));
|
||||
|
||||
return str_contains($haystack, mb_strtolower($keyword));
|
||||
}
|
||||
|
||||
private function csvText(mixed $value): string
|
||||
{
|
||||
$value = (string) $value;
|
||||
|
||||
return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value;
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ use App\Models\Business\CourierQuoteSignature;
|
||||
use App\Models\Business\CourierQuoteVersion;
|
||||
use App\Models\Business\CourierSigningSubject;
|
||||
use App\Services\Esign\EsignClient;
|
||||
use App\Support\CourierAgreementFileArchive;
|
||||
use App\Support\CourierAgreementSignatureService;
|
||||
use App\Support\CourierQuoteConfirmation;
|
||||
use App\Support\CourierQuoteSignatureService;
|
||||
@ -18,6 +19,7 @@ use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class CourierAgreementSignatures extends Controller
|
||||
@ -28,6 +30,7 @@ class CourierAgreementSignatures extends Controller
|
||||
private readonly CourierAgreementSignatureService $service,
|
||||
private readonly CourierQuoteSignatureService $quoteSignatureService,
|
||||
private readonly CourierQuoteConfirmation $quoteConfirmation,
|
||||
private readonly CourierAgreementFileArchive $agreementFileArchive,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -53,6 +56,13 @@ class CourierAgreementSignatures extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($requestedType, ['account-proof', 'account-change'], true)
|
||||
&& ! $request->filled('counterparty_signer_type')) {
|
||||
// Existing integrations omit this field and historically meant an
|
||||
// enterprise signer, so keep that behavior as the compatibility default.
|
||||
$request->merge(['counterparty_signer_type' => 'organization']);
|
||||
}
|
||||
|
||||
$data = $request->validate($this->rules());
|
||||
$type = $data['document_type'];
|
||||
if ($type === 'authorization' && count($data['authorization_items'] ?? []) === 0) {
|
||||
@ -76,6 +86,20 @@ class CourierAgreementSignatures extends Controller
|
||||
$data['shared_courier_name'] = $sharedCouriers[0];
|
||||
$data['shared_note'] = trim((string) ($data['shared_note'] ?? '')) ?: null;
|
||||
}
|
||||
if (in_array($type, ['account-proof', 'account-change'], true)) {
|
||||
$data['counterparty_signer_type'] = $data['counterparty_signer_type'] ?? 'organization';
|
||||
$data['operator_name'] = trim((string) ($data['operator_name'] ?? '')) ?: null;
|
||||
|
||||
if ($data['counterparty_signer_type'] === 'organization' && blank($data['operator_name'])) {
|
||||
throw ValidationException::withMessages(['operator_name' => '企业签署主体必须填写 E 签宝实名认证姓名。']);
|
||||
}
|
||||
|
||||
if ($data['counterparty_signer_type'] === 'person') {
|
||||
// A personal signer uses the same real name for the signer and
|
||||
// the E-sign identity; the UI should not require it twice.
|
||||
$data['operator_name'] = trim((string) $data['counterparty_organization_name']);
|
||||
}
|
||||
}
|
||||
$this->validateEsignAccount($data['esign_account'], 'esign_account');
|
||||
if (! empty($data['secondary_esign_account'])) {
|
||||
$this->validateEsignAccount($data['secondary_esign_account'], 'secondary_esign_account');
|
||||
@ -88,7 +112,7 @@ class CourierAgreementSignatures extends Controller
|
||||
// signing_subject identifies our initiating company. The E-sign
|
||||
// organization must be the counterparty that receives this request.
|
||||
$data['organization_name'] = $this->counterpartyOrganizationName($data);
|
||||
$data['operator_name'] = $data['operator_name'] ?? $data['courier_name'];
|
||||
$data['operator_name'] = trim((string) ($data['operator_name'] ?? '')) ?: null;
|
||||
$data['operator_account'] = $data['esign_account'];
|
||||
|
||||
$data['payload'] = $this->payload($data);
|
||||
@ -205,15 +229,25 @@ class CourierAgreementSignatures extends Controller
|
||||
->with('success', "{$signature->documentLabel()}已人工确认并归档。");
|
||||
}
|
||||
|
||||
public function download(CourierAgreementSignature $agreementSignature, string $kind): StreamedResponse
|
||||
public function download(CourierAgreementSignature $agreementSignature, string $kind): StreamedResponse|RedirectResponse
|
||||
{
|
||||
$this->ensureCanManage();
|
||||
[$path, $suffix] = match ($kind) {
|
||||
'source' => [$agreementSignature->source_pdf_path, '签署原文'],
|
||||
'signed' => [$agreementSignature->signed_pdf_path, '已签署协议'],
|
||||
'certificate' => [$agreementSignature->certificate_path, '签署完成证书'],
|
||||
default => abort(404),
|
||||
};
|
||||
try {
|
||||
[$path, $suffix] = match ($kind) {
|
||||
'source' => [$this->agreementFileArchive->sourcePath($agreementSignature), '签署原文'],
|
||||
'signed' => [$agreementSignature->signed_pdf_path, '已签署协议'],
|
||||
'certificate' => [$agreementSignature->certificate_path, '签署完成证书'],
|
||||
default => abort(404),
|
||||
};
|
||||
} catch (RuntimeException $exception) {
|
||||
return redirect()
|
||||
->route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'agreement-signing',
|
||||
'agreement_view' => 'tasks',
|
||||
])
|
||||
->with('error', $exception->getMessage());
|
||||
}
|
||||
|
||||
abort_if(blank($path) || ! \Storage::disk('uploads')->exists($path), 404, '归档文件不存在。');
|
||||
|
||||
@ -265,10 +299,13 @@ class CourierAgreementSignatures extends Controller
|
||||
'quote_version_id' => ['required_if:document_type,quote-confirmation', 'nullable', 'integer', 'min:1'],
|
||||
'signing_subject' => ['required', 'string', 'min:2', 'max:255'],
|
||||
'company_full_name' => ['required', 'string', 'min:2', 'max:255'],
|
||||
'signature_date' => ['required', 'date'],
|
||||
'signing_deadline' => $date,
|
||||
'esign_account' => ['required', 'string', 'max:255'],
|
||||
'secondary_esign_account' => ['required_if:document_type,entity-change,cooperation-contract,authorization', 'nullable', 'string', 'max:255'],
|
||||
'operator_name' => ['nullable', 'string', 'max:100'],
|
||||
'operator_name' => ['nullable', 'string', 'min:2', 'max:100'],
|
||||
'counterparty_organization_name' => ['required_if:document_type,account-proof,account-change', 'nullable', 'string', 'min:2', 'max:255'],
|
||||
'counterparty_signer_type' => ['required_if:document_type,account-proof,account-change', 'nullable', 'in:organization,person'],
|
||||
'archive_mode' => ['required', 'in:auto,manual'],
|
||||
'account_proof_effective_date' => ['required_if:document_type,account-proof', 'date'],
|
||||
'account_proof_account_name' => ['required_if:document_type,account-proof', 'string', 'max:255'],
|
||||
@ -286,12 +323,10 @@ class CourierAgreementSignatures extends Controller
|
||||
'entity_change_date' => ['required_if:document_type,entity-change', 'date'],
|
||||
'changed_company_name' => ['required_if:document_type,entity-change', 'string', 'max:255'],
|
||||
'changed_company_start_date' => ['required_if:document_type,entity-change', 'date'],
|
||||
'entity_proof_date' => ['required_if:document_type,entity-change', 'date'],
|
||||
'contract_partner_name' => ['required_if:document_type,shared-contract', 'string', 'max:255'],
|
||||
'shared_courier_names' => ['nullable', 'array', 'min:1'],
|
||||
'shared_courier_names.*' => ['required', 'string', 'max:255'],
|
||||
'shared_courier_name' => ['nullable', 'string', 'max:255'],
|
||||
'shared_proof_date' => ['nullable', 'date'],
|
||||
'shared_note' => ['nullable', 'string', 'max:2000'],
|
||||
'cooperation_party_a_name' => ['required_if:document_type,cooperation-contract', 'string', 'max:255'],
|
||||
'cooperation_party_a_phone' => ['required_if:document_type,cooperation-contract', 'string', 'max:64'],
|
||||
@ -329,7 +364,7 @@ class CourierAgreementSignatures extends Controller
|
||||
->findOrFail((int) $data['quote_version_id']);
|
||||
$this->ensureQuoteVersionMatchesCourier($version, $data['courier_name']);
|
||||
|
||||
$signature = $this->quoteSignatureService->prepareVersion($version, user_id());
|
||||
$signature = $this->quoteSignatureService->prepareVersion($version, user_id(), $data['signature_date']);
|
||||
$account = trim((string) $data['esign_account']);
|
||||
$signature = $this->quoteSignatureService->send($signature, [
|
||||
'organization_name' => $version->carrier_name,
|
||||
@ -357,8 +392,7 @@ class CourierAgreementSignatures extends Controller
|
||||
private function counterpartyOrganizationName(array $data): string
|
||||
{
|
||||
return match ($data['document_type']) {
|
||||
'account-proof' => trim((string) $data['account_proof_account_name']),
|
||||
'account-change' => trim((string) $data['new_account_name']),
|
||||
'account-proof', 'account-change' => trim((string) $data['counterparty_organization_name']),
|
||||
'shared-contract' => trim((string) (($data['shared_courier_names'][0] ?? null) ?: ($data['shared_courier_name'] ?? $data['courier_name']))),
|
||||
'entity-change' => trim((string) ($data['former_company_name'] ?? $data['courier_name'])),
|
||||
'authorization' => trim((string) ($data['principal_name'] ?? $data['courier_name'])),
|
||||
|
||||
170
app/Http/Controllers/Business/CourierPayableSummaries.php
Normal file
170
app/Http/Controllers/Business/CourierPayableSummaries.php
Normal file
@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Business\CourierPayableSummaryFilter;
|
||||
use App\Business\CourierPayableSummaryStore;
|
||||
use App\Utilities\SidebarPermissions;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class CourierPayableSummaries extends Controller
|
||||
{
|
||||
private const IMPORT_TEMPLATES = [
|
||||
'freight-difference' => [
|
||||
'path' => 'templates/courier-payables/freight-difference-import-template.xlsx',
|
||||
'ascii_name' => 'freight_difference_import_template.xlsx',
|
||||
'download_name' => '运费差导入模板.xlsx',
|
||||
],
|
||||
'other-surcharge-summary' => [
|
||||
'path' => 'templates/courier-payables/other-surcharge-import-template.xlsx',
|
||||
'ascii_name' => 'other_surcharge_import_template.xlsx',
|
||||
'download_name' => '其他附加费导入模板.xlsx',
|
||||
],
|
||||
'return-fee' => [
|
||||
'path' => 'templates/courier-payables/return-fee-import-template.xlsx',
|
||||
'ascii_name' => 'return_fee_import_template.xlsx',
|
||||
'download_name' => '退件费导入模板.xlsx',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly CourierPayableSummaryStore $store,
|
||||
private readonly CourierPayableSummaryFilter $filter,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
}
|
||||
|
||||
public function import(Request $request, string $summaryPage): RedirectResponse
|
||||
{
|
||||
$this->authorizePage($summaryPage);
|
||||
$this->store->title($summaryPage);
|
||||
$request->validate([
|
||||
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->store->import(
|
||||
$request->file('import'),
|
||||
(int) (company_id() ?: 1),
|
||||
$summaryPage,
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
report($exception);
|
||||
|
||||
return $this->redirectToPage($summaryPage)->with('error', '汇总数据保存失败,请稍后重试。');
|
||||
}
|
||||
|
||||
return $this->redirectToPage($summaryPage)->with(
|
||||
'success',
|
||||
"导入完成:{$result['rows']} 条数据,{$result['columns']} 个字段。",
|
||||
);
|
||||
}
|
||||
|
||||
public function template(string $summaryPage): BinaryFileResponse
|
||||
{
|
||||
$this->authorizePage($summaryPage);
|
||||
$template = self::IMPORT_TEMPLATES[$summaryPage] ?? null;
|
||||
abort_unless(is_array($template), 404);
|
||||
|
||||
$path = resource_path($template['path']);
|
||||
abort_unless(is_file($path), 404);
|
||||
|
||||
$response = response()->download($path, $template['ascii_name'], [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
|
||||
return $response->setContentDisposition(
|
||||
'attachment',
|
||||
$template['download_name'],
|
||||
$template['ascii_name'],
|
||||
);
|
||||
}
|
||||
|
||||
public function export(Request $request, string $summaryPage): StreamedResponse|RedirectResponse
|
||||
{
|
||||
$this->authorizePage($summaryPage);
|
||||
$title = $this->store->title($summaryPage);
|
||||
|
||||
try {
|
||||
$snapshot = $this->store->snapshot((int) (company_id() ?: 1), $summaryPage);
|
||||
} catch (RuntimeException) {
|
||||
return $this->redirectToPage($summaryPage)->with('error', '汇总数据暂时无法读取,请重新导入后再试。');
|
||||
}
|
||||
|
||||
if ($snapshot === null) {
|
||||
return $this->redirectToPage($summaryPage)->with('error', '当前没有可导出的汇总数据。');
|
||||
}
|
||||
|
||||
$filters = $this->filter->fromRequest($request);
|
||||
$headers = $snapshot['headers'];
|
||||
$rows = $this->filter->apply(collect($snapshot['rows']), $headers, $filters)->values();
|
||||
$dateColumn = $this->filter->dateColumnIndex($headers);
|
||||
|
||||
return response()->streamDownload(function () use ($headers, $rows, $dateColumn): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
|
||||
if ($csv === false) {
|
||||
throw new RuntimeException('无法创建汇总数据导出流。');
|
||||
}
|
||||
|
||||
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
fputcsv($csv, array_map([$this, 'csvText'], $headers));
|
||||
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($csv, array_map(
|
||||
fn (mixed $value, int $column): string => $this->csvText($value, $column === $dateColumn),
|
||||
$row,
|
||||
array_keys($row),
|
||||
));
|
||||
}
|
||||
|
||||
fclose($csv);
|
||||
}, $title . '_' . now()->format('Ymd_His') . '.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
private function redirectToPage(string $summaryPage): RedirectResponse
|
||||
{
|
||||
return redirect()->route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => $summaryPage,
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizePage(string $summaryPage): void
|
||||
{
|
||||
$section = collect(SidebarPermissions::navigationFor(user()))
|
||||
->firstWhere('slug', 'courier-payables');
|
||||
$pages = collect($section['pages'] ?? [])->flatMap(static fn (array $page): array => [
|
||||
$page,
|
||||
...($page['children'] ?? []),
|
||||
]);
|
||||
|
||||
abort_unless($pages->contains('slug', $summaryPage), 403, '当前账号没有该页面权限。');
|
||||
}
|
||||
|
||||
private function csvText(mixed $value, bool $preserveYearMonth = false): string
|
||||
{
|
||||
$value = (string) $value;
|
||||
|
||||
if ($preserveYearMonth && preg_match('/^\d{4}-(?:0[1-9]|1[0-2])$/', $value) === 1) {
|
||||
return '="' . $value . '"';
|
||||
}
|
||||
|
||||
if (preg_match('/^[+-]?(?:(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|\.\d+)%?$/u', $value) === 1) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value;
|
||||
}
|
||||
}
|
||||
@ -19,11 +19,14 @@ class CourierQuoteSignatures extends Controller
|
||||
{
|
||||
}
|
||||
|
||||
public function prepare(CourierQuoteVersion $quoteVersion): RedirectResponse
|
||||
public function prepare(Request $request, CourierQuoteVersion $quoteVersion): RedirectResponse
|
||||
{
|
||||
$this->ensureCanManage();
|
||||
abort_unless($quoteVersion->status === 'approved', 422, '只有已审核报价版本可以生成签署文件。');
|
||||
$signature = $this->service->prepareVersion($quoteVersion, user_id());
|
||||
$data = $request->validate([
|
||||
'signature_date' => ['required', 'date'],
|
||||
]);
|
||||
$signature = $this->service->prepareVersion($quoteVersion, user_id(), $data['signature_date']);
|
||||
|
||||
return redirect()->route('business.courier-quote-changes.show', $quoteVersion->source_change_id)
|
||||
->with('success', "{$quoteVersion->version_code} 的签署 PDF 已生成,文件校验值已固定。")
|
||||
@ -40,9 +43,15 @@ class CourierQuoteSignatures extends Controller
|
||||
'operator_name' => ['required', 'string', 'min:2', 'max:100'],
|
||||
'operator_account' => ['required', 'string', 'max:255'],
|
||||
'notice_type' => ['required', 'in:1,2'],
|
||||
'signature_date' => ['required', 'date'],
|
||||
'expires_at' => ['required', 'date', 'after:now', 'before_or_equal:' . now()->addDays(90)->toDateTimeString()],
|
||||
]);
|
||||
$this->validateAccountForNotice($data['operator_account'], $data['notice_type']);
|
||||
$quoteSignature = $this->service->prepareVersion(
|
||||
$quoteSignature->version,
|
||||
user_id(),
|
||||
$data['signature_date'],
|
||||
);
|
||||
$signature = $this->service->send($quoteSignature, $data, user_id());
|
||||
$environment = $signature->environment === 'sandbox' ? '沙箱' : '正式环境';
|
||||
|
||||
|
||||
@ -3,15 +3,20 @@
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Business\CourierSurchargeQuoteRepository;
|
||||
use App\Business\CourierSurchargeQuoteWorkbookImporter;
|
||||
use App\Models\Business\CourierSurchargeQuote;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class CourierSurchargeQuotes extends Controller
|
||||
{
|
||||
public function __construct(private readonly CourierSurchargeQuoteRepository $quotes)
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CourierSurchargeQuoteRepository $quotes,
|
||||
private readonly CourierSurchargeQuoteWorkbookImporter $workbookImporter,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
$this->middleware('business.page:courier-payables,courier-surcharge');
|
||||
@ -49,6 +54,79 @@ class CourierSurchargeQuotes extends Controller
|
||||
return $this->redirect()->with('success', '报价规则状态已更新。');
|
||||
}
|
||||
|
||||
public function importTemplate(): StreamedResponse
|
||||
{
|
||||
abort_unless($this->quotes->ready(), 503, '附加费报价数据表尚未完成迁移,请联系管理员。');
|
||||
|
||||
return $this->workbookImporter->template();
|
||||
}
|
||||
|
||||
public function importPreview(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($this->quotes->ready(), 503, '附加费报价数据表尚未完成迁移,请联系管理员。');
|
||||
|
||||
$file = $request->validate([
|
||||
'import_file' => ['required', 'file', 'max:10240'],
|
||||
])['import_file'];
|
||||
$preview = $this->workbookImporter->preview($file);
|
||||
$token = Str::random(48);
|
||||
$previews = collect((array) $request->session()->get('courier_surcharge_import_previews', []))
|
||||
->filter(fn (mixed $item): bool => is_array($item) && (int) ($item['expires_at'] ?? 0) >= now()->timestamp)
|
||||
->take(-2)
|
||||
->all();
|
||||
$previews[$token] = [
|
||||
'company_id' => (int) company_id(),
|
||||
'user_id' => (int) user_id(),
|
||||
'expires_at' => now()->addMinutes(20)->timestamp,
|
||||
'rows' => $preview['rows'],
|
||||
];
|
||||
$request->session()->put('courier_surcharge_import_previews', $previews);
|
||||
|
||||
return $this->redirect()->with('courierSurchargeImportPreview', $preview + ['token' => $token]);
|
||||
}
|
||||
|
||||
public function importCommit(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($this->quotes->ready(), 503, '附加费报价数据表尚未完成迁移,请联系管理员。');
|
||||
|
||||
$validated = $request->validate([
|
||||
'preview_token' => ['required', 'string', 'size:48'],
|
||||
'duplicate_policy' => ['required', 'in:' . CourierSurchargeQuoteWorkbookImporter::DUPLICATE_SKIP . ',' . CourierSurchargeQuoteWorkbookImporter::DUPLICATE_UPDATE],
|
||||
]);
|
||||
$previews = (array) $request->session()->get('courier_surcharge_import_previews', []);
|
||||
$payload = $previews[$validated['preview_token']] ?? null;
|
||||
|
||||
if (! is_array($payload)
|
||||
|| (int) ($payload['company_id'] ?? 0) !== (int) company_id()
|
||||
|| (int) ($payload['user_id'] ?? 0) !== (int) user_id()
|
||||
|| (int) ($payload['expires_at'] ?? 0) < now()->timestamp) {
|
||||
unset($previews[$validated['preview_token']]);
|
||||
$request->session()->put('courier_surcharge_import_previews', $previews);
|
||||
|
||||
return $this->redirect()->withErrors([
|
||||
'import' => '导入预览已失效或不属于当前账号,请重新选择文件预览。',
|
||||
]);
|
||||
}
|
||||
|
||||
$counts = $this->workbookImporter->commit(
|
||||
(array) ($payload['rows'] ?? []),
|
||||
$validated['duplicate_policy'],
|
||||
(int) user_id(),
|
||||
);
|
||||
unset($previews[$validated['preview_token']]);
|
||||
$request->session()->put('courier_surcharge_import_previews', $previews);
|
||||
|
||||
return $this->redirect()->with(
|
||||
'success',
|
||||
sprintf(
|
||||
'附加费报价导入完成:新增 %d 条,更新 %d 条,跳过 %d 条。',
|
||||
$counts['created'],
|
||||
$counts['updated'],
|
||||
$counts['skipped'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function validated(Request $request): array
|
||||
{
|
||||
@ -56,6 +134,7 @@ class CourierSurchargeQuotes extends Controller
|
||||
'courier' => ['required', 'string', 'max:191'],
|
||||
'scope' => ['required', 'in:' . implode(',', CourierSurchargeQuoteRepository::SCOPES)],
|
||||
'reconciler' => ['required', 'string', 'max:100'],
|
||||
'supplier' => ['nullable', 'string', 'max:191'],
|
||||
'types' => ['required', 'array', 'min:1', 'max:50'],
|
||||
'types.*' => ['string', 'in:' . implode(',', $this->quotes->activeTypeNames())],
|
||||
'mode' => ['required', 'in:' . implode(',', CourierSurchargeQuoteRepository::MODES)],
|
||||
@ -67,6 +146,7 @@ class CourierSurchargeQuotes extends Controller
|
||||
|
||||
$data['courier'] = trim($data['courier']);
|
||||
$data['reconciler'] = trim($data['reconciler']);
|
||||
$data['supplier'] = trim((string) ($data['supplier'] ?? '')) ?: null;
|
||||
$data['types'] = array_values(array_unique($data['types']));
|
||||
|
||||
return $data;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
152
app/Http/Controllers/Business/PayablesFinalActions.php
Normal file
152
app/Http/Controllers/Business/PayablesFinalActions.php
Normal file
@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\Services\PayablesFinalImporter;
|
||||
use Modules\EcommerceFinance\Services\PayablesFinalRegistry;
|
||||
use Modules\EcommerceFinance\Services\PayablesFieldNormalizer;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableImportTemplate;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* 「终版」目录的按表数据:界面与基础页面一致,数据独立存放
|
||||
* (数据集键 final:<目录>:<页面>),初始为空,由页面上的导入、导出、查询、编辑维护。
|
||||
*/
|
||||
class PayablesFinalActions extends Controller
|
||||
{
|
||||
public const DATASET_PREFIX = 'final:';
|
||||
|
||||
public function __construct(
|
||||
private readonly PayablesFinalRegistry $registry,
|
||||
private readonly SupplierPayableImportTemplate $template,
|
||||
private readonly SupplierPayableOverrideStore $overrides,
|
||||
private readonly PayablesFinalImporter $importer,
|
||||
private readonly PayablesFieldNormalizer $normalizer,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
}
|
||||
|
||||
public function definition(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'dataset' => $this->dataset($request),
|
||||
'fields' => collect($this->fields($request))
|
||||
->map(static fn (array $definition, string $name): array => ['name' => $name] + $definition)
|
||||
->values()
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function template(Request $request): StreamedResponse
|
||||
{
|
||||
return $this->template->download(
|
||||
$this->registry->title($this->section($request), $this->dataset($request)),
|
||||
$this->fields($request),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
public function import(Request $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
$request->validate(['import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240']]);
|
||||
$result = $this->importer->import(
|
||||
$this->fields($request),
|
||||
$this->storeDataset($request),
|
||||
$request->file('import'),
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
$message = "导入完成:新增 {$result['created']} 条,更新 {$result['updated']} 条。";
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['message' => $message] + $result)
|
||||
: back()->with('success', $message);
|
||||
}
|
||||
|
||||
public function update(Request $request, string $recordKey): JsonResponse|RedirectResponse
|
||||
{
|
||||
$values = $this->normalizer->normalize($this->fields($request), (array) $request->input('values', []));
|
||||
$result = $this->overrides->save(
|
||||
$this->storeDataset($request),
|
||||
$recordKey,
|
||||
$values,
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['message' => '保存成功。'] + $result)
|
||||
: back()->with('success', '保存成功。');
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$fields = $this->fields($request);
|
||||
$title = $this->registry->title($this->section($request), $this->dataset($request));
|
||||
$rows = $this->overrides->listRows($this->storeDataset($request), (int) (company_id() ?: 1));
|
||||
|
||||
return response()->streamDownload(function () use ($rows, $fields): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
if ($csv === false) {
|
||||
throw new RuntimeException('无法创建导出文件。');
|
||||
}
|
||||
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
fputcsv($csv, array_merge(['记录标识'], array_column($fields, 'label')));
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$values = [$this->csvText($row['_action_key'] ?? '')];
|
||||
foreach ($fields as $field => $definition) {
|
||||
$value = $row[$field] ?? '';
|
||||
$values[] = $definition['type'] === 'number' && is_numeric($value)
|
||||
? $value
|
||||
: $this->csvText($value);
|
||||
}
|
||||
fputcsv($csv, $values);
|
||||
}
|
||||
fclose($csv);
|
||||
}, $title . '_' . now()->format('Ymd_His') . '.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
private function section(Request $request): string
|
||||
{
|
||||
return (string) $request->route('finalSection');
|
||||
}
|
||||
|
||||
private function dataset(Request $request): string
|
||||
{
|
||||
$dataset = (string) $request->route('dataset');
|
||||
$this->registry->title($this->section($request), $dataset);
|
||||
|
||||
return $dataset;
|
||||
}
|
||||
|
||||
/** @return array<string,array{label:string,type:string}> */
|
||||
private function fields(Request $request): array
|
||||
{
|
||||
return $this->registry->fields($this->section($request), $this->dataset($request));
|
||||
}
|
||||
|
||||
private function storeDataset(Request $request): string
|
||||
{
|
||||
$section = $this->section($request) === PayablesFinalRegistry::SECTION_COURIER ? 'courier' : 'supplier';
|
||||
|
||||
return self::DATASET_PREFIX . $section . ':' . $this->dataset($request);
|
||||
}
|
||||
|
||||
private function csvText(mixed $value): string
|
||||
{
|
||||
$value = (string) $value;
|
||||
|
||||
return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value;
|
||||
}
|
||||
}
|
||||
196
app/Http/Controllers/Business/ProductPrices.php
Normal file
196
app/Http/Controllers/Business/ProductPrices.php
Normal file
@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\ProductPriceWorkbookImporter;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableImportTemplate;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ProductPrices extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProductPriceMirrorRepository $repository,
|
||||
private readonly ProductPriceWorkbookImporter $importer,
|
||||
private readonly SupplierPayableImportTemplate $template,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse|RedirectResponse
|
||||
{
|
||||
if (! $this->repository->ready() || ! $this->repository->initialized()) {
|
||||
return $this->redirectToPage($request)
|
||||
->with('error', '商品价格镜像尚未完成首次同步,暂时不能导出。');
|
||||
}
|
||||
|
||||
$filters = $this->filters($request);
|
||||
$companyId = (int) (company_id() ?: 1);
|
||||
|
||||
return response()->streamDownload(function () use ($filters, $companyId): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
|
||||
if ($csv === false) {
|
||||
throw new RuntimeException('无法创建商品价格导出流。');
|
||||
}
|
||||
|
||||
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
fputcsv($csv, [
|
||||
'记录标识', '业务编号', '商品编码', 'SPU', '商品名称', '类目', '生产状态',
|
||||
'最低售价', '计算限价', '采购价', '运费', '京喜限价', '流程状态',
|
||||
'审批结果', '流程标题', '流程完成时间', '人工维护', '维护时间',
|
||||
]);
|
||||
|
||||
foreach ($this->repository->exportRows($filters, $companyId) as $row) {
|
||||
fputcsv($csv, [
|
||||
$this->csvText($row->upstream_key ?? ''),
|
||||
$this->csvText($row->business_id ?? ''),
|
||||
$this->csvText($row->barcode ?? ''),
|
||||
$this->csvText($row->spu ?? ''),
|
||||
$this->csvText($row->product_name ?? ''),
|
||||
$this->csvText($row->category ?? ''),
|
||||
$this->csvText($row->production_status ?? ''),
|
||||
$row->minimum_price ?? null,
|
||||
$row->price_limit ?? null,
|
||||
$row->purchase_price ?? null,
|
||||
$row->shipping_fee ?? null,
|
||||
$row->jingxi_limit_price ?? null,
|
||||
$this->csvText($row->approval_status ?? ''),
|
||||
$this->csvText($row->approval_result ?? ''),
|
||||
$this->csvText($row->workflow_title ?? ''),
|
||||
$this->csvText($row->completed_at ?? ''),
|
||||
(int) ($row->is_overridden ?? 0) === 1 ? '是' : '否',
|
||||
$this->csvText($row->override_updated_at ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($csv);
|
||||
}, '商品价格_' . now()->format('Ymd_His') . '.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
public function import(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'],
|
||||
]);
|
||||
|
||||
$result = $this->importer->import(
|
||||
$request->file('import'),
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
|
||||
return $this->redirectToPage($request)->with(
|
||||
'success',
|
||||
"导入完成:新增维护 {$result['created']} 条,更新维护 {$result['updated']} 条。",
|
||||
);
|
||||
}
|
||||
|
||||
public function template(): StreamedResponse
|
||||
{
|
||||
return $this->template->download('商品价格', $this->importer->templateFields());
|
||||
}
|
||||
|
||||
public function update(Request $request, string $upstreamKey): RedirectResponse
|
||||
{
|
||||
$values = $request->validate(
|
||||
[
|
||||
'barcode' => ['required', 'string', 'max:191'],
|
||||
'spu' => ['nullable', 'string', 'max:191'],
|
||||
'product_name' => ['nullable', 'string', 'max:5000'],
|
||||
'category' => ['nullable', 'string', 'max:255'],
|
||||
'production_status' => ['nullable', 'string', 'max:120'],
|
||||
'minimum_price' => ['nullable', 'numeric', 'decimal:0,4', 'min:0', 'max:9999999999999999.9999'],
|
||||
'price_limit' => ['nullable', 'numeric', 'decimal:0,4', 'min:0', 'max:9999999999999999.9999'],
|
||||
'purchase_price' => ['nullable', 'numeric', 'decimal:0,4', 'min:0', 'max:9999999999999999.9999'],
|
||||
'shipping_fee' => ['nullable', 'numeric', 'decimal:0,4', 'min:0', 'max:9999999999999999.9999'],
|
||||
'jingxi_limit_price' => ['nullable', 'numeric', 'decimal:0,4', 'min:0', 'max:9999999999999999.9999'],
|
||||
],
|
||||
[
|
||||
'minimum_price.decimal' => '最低售价最多支持 4 位小数。',
|
||||
'price_limit.decimal' => '计算限价最多支持 4 位小数。',
|
||||
'purchase_price.decimal' => '采购价最多支持 4 位小数。',
|
||||
'shipping_fee.decimal' => '运费最多支持 4 位小数。',
|
||||
'jingxi_limit_price.decimal' => '京喜限价最多支持 4 位小数。',
|
||||
],
|
||||
);
|
||||
|
||||
try {
|
||||
$this->repository->applyOverrides(
|
||||
[$upstreamKey => $values],
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
'manual',
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
if (str_starts_with($exception->getMessage(), 'product_price_record_not_found:')) {
|
||||
abort(404, '商品价格记录不存在。');
|
||||
}
|
||||
|
||||
if ($exception->getMessage() === 'product_price_maintenance_schema_missing') {
|
||||
throw ValidationException::withMessages([
|
||||
'barcode' => '商品价格维护功能尚未完成初始化,请稍后重试。',
|
||||
]);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return $this->redirectToPage($request)->with('success', '商品价格已保存。');
|
||||
}
|
||||
|
||||
/** @return array{barcode:string,completed_from:string,completed_to:string} */
|
||||
private function filters(Request $request): array
|
||||
{
|
||||
return [
|
||||
'barcode' => mb_substr(trim((string) $request->query('barcode', '')), 0, 191),
|
||||
'completed_from' => $this->date((string) $request->query('completed_from', '')),
|
||||
'completed_to' => $this->date((string) $request->query('completed_to', '')),
|
||||
];
|
||||
}
|
||||
|
||||
private function redirectToPage(Request $request): RedirectResponse
|
||||
{
|
||||
$filters = $this->filters($request);
|
||||
$query = array_filter([
|
||||
...$filters,
|
||||
'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' => 'product-prices',
|
||||
]);
|
||||
|
||||
return redirect()->to($url . ($query === [] ? '' : '?' . http_build_query($query)));
|
||||
}
|
||||
|
||||
private function date(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
[$year, $month, $day] = array_map('intval', explode('-', $value));
|
||||
|
||||
return checkdate($month, $day, $year) ? $value : '';
|
||||
}
|
||||
|
||||
private function csvText(mixed $value): string
|
||||
{
|
||||
$value = (string) $value;
|
||||
|
||||
return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value;
|
||||
}
|
||||
}
|
||||
196
app/Http/Controllers/Business/SupplierPayableActions.php
Normal file
196
app/Http/Controllers/Business/SupplierPayableActions.php
Normal file
@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository;
|
||||
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
|
||||
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableMaintenanceRegistry;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableImportTemplate;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableWorkbookImporter;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class SupplierPayableActions extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SupplierPayableRepository $supplier,
|
||||
private readonly GoodsReconciliationRepository $goods,
|
||||
private readonly SupplierPayableReportRepository $reports,
|
||||
private readonly SupplierPayableMaintenanceRegistry $registry,
|
||||
private readonly SupplierPayableImportTemplate $template,
|
||||
private readonly SupplierPayableOverrideStore $overrides,
|
||||
private readonly SupplierPayableWorkbookImporter $importer,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
}
|
||||
|
||||
public function definition(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json($this->registry->publicDefinition($this->dataset($request)));
|
||||
}
|
||||
|
||||
public function update(Request $request, string $recordKey): JsonResponse|RedirectResponse
|
||||
{
|
||||
$dataset = $this->dataset($request);
|
||||
$values = $this->registry->normalize($dataset, (array) $request->input('values', []));
|
||||
$result = $this->overrides->save(
|
||||
$dataset,
|
||||
$recordKey,
|
||||
$values,
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['message' => '保存成功。'] + $result)
|
||||
: back()->with('success', '保存成功。');
|
||||
}
|
||||
|
||||
public function import(Request $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
$request->validate(['import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240']]);
|
||||
$result = $this->importer->import(
|
||||
$this->dataset($request),
|
||||
$request->file('import'),
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
$message = "导入完成:新增维护 {$result['created']} 条,更新维护 {$result['updated']} 条。";
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['message' => $message] + $result)
|
||||
: back()->with('success', $message);
|
||||
}
|
||||
|
||||
public function template(Request $request): StreamedResponse
|
||||
{
|
||||
$dataset = $this->dataset($request);
|
||||
|
||||
return $this->template->download($this->title($dataset), $this->registry->fields($dataset));
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$dataset = $this->dataset($request);
|
||||
$fields = $this->registry->fields($dataset);
|
||||
$filters = $this->filters($dataset, $request);
|
||||
$companyId = (int) (company_id() ?: 1);
|
||||
|
||||
return response()->streamDownload(function () use ($dataset, $fields, $filters, $companyId): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
if ($csv === false) {
|
||||
throw new RuntimeException('无法创建导出文件。');
|
||||
}
|
||||
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
fputcsv($csv, array_merge(['记录标识'], array_column($fields, 'label')));
|
||||
|
||||
foreach ($this->allRows($dataset, $filters) as $batch) {
|
||||
foreach ($this->overrides->decorate($dataset, $batch, $companyId) as $row) {
|
||||
$values = [$this->csvText($row['_action_key'] ?? '')];
|
||||
foreach ($fields as $field => $definition) {
|
||||
$values[] = $definition['type'] === 'number' && is_numeric($row[$field] ?? null)
|
||||
? $row[$field]
|
||||
: $this->csvText($row[$field] ?? '');
|
||||
}
|
||||
fputcsv($csv, $values);
|
||||
}
|
||||
}
|
||||
fclose($csv);
|
||||
}, $this->title($dataset) . '_' . now()->format('Ymd_His') . '.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return iterable<array<int,array<string,mixed>>> */
|
||||
private function allRows(string $dataset, array $filters): iterable
|
||||
{
|
||||
if ($dataset === 'payment-reconciliation' && ($filters['record_type'] ?? 'all') === 'all') {
|
||||
foreach (['purchase', 'sale'] as $type) {
|
||||
yield from $this->allRows($dataset, array_replace($filters, ['record_type' => $type]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$page = 1;
|
||||
do {
|
||||
$result = $this->page($dataset, $filters, $page, 100);
|
||||
$rows = array_slice((array) ($result['data'] ?? $result['rows'] ?? []), 0, 100);
|
||||
if ($rows !== []) {
|
||||
yield $rows;
|
||||
}
|
||||
$total = (int) ($result['total'] ?? -1);
|
||||
$more = $rows !== [] && ($total < 0 ? count($rows) === 100 : $page * 100 < $total);
|
||||
$page++;
|
||||
} while ($more);
|
||||
}
|
||||
|
||||
private function page(string $dataset, array $filters, int $page, int $limit): array
|
||||
{
|
||||
return match ($dataset) {
|
||||
'payment-reconciliation' => $this->goods->list($filters, $page, $limit),
|
||||
'return-reconciliation' => $this->supplier->returnReconciliations($filters, $page, $limit),
|
||||
'invoice-deductions' => $this->supplier->invoiceDeductions($filters, $page, $limit),
|
||||
'compensation-details' => $this->supplier->compensationDetails($filters, $page, $limit),
|
||||
'advance-details' => $this->supplier->advanceDetails($filters, $page, $limit),
|
||||
'sample-deductions' => $this->supplier->sampleDeductions($filters, $page, $limit),
|
||||
'delivery-unpriced' => $this->supplier->deliveryUnpriced($filters, $page, $limit),
|
||||
'return-unpriced' => $this->supplier->returnUnpriced($filters, $page, $limit),
|
||||
'other-adjustments' => $this->supplier->otherAdjustments($filters, $page, $limit),
|
||||
'dropshipping-express' => $this->supplier->dropshippingExpress($filters, $page, $limit),
|
||||
'board-deductions' => $this->reports->boardDeductions($filters, $page, $limit),
|
||||
'supplier-payables-report' => $this->reports->supplierPayables($filters, $page, $limit),
|
||||
};
|
||||
}
|
||||
|
||||
private function filters(string $dataset, Request $request): array
|
||||
{
|
||||
$allowed = match ($dataset) {
|
||||
'payment-reconciliation' => ['record_type', 'supplier', 'warehouse', 'stock_no', 'purchase_no', 'product_code', 'express_no', 'send_time_from', 'send_time_to', 'create_time_from', 'create_time_to', 'sent_status', 'has_diff'],
|
||||
'return-reconciliation', 'return-unpriced' => ['supplier', 'warehouse', 'sku_id', 'express_no', 'date_from', 'date_to'],
|
||||
'invoice-deductions', 'compensation-details', 'advance-details', 'other-adjustments' => ['supplier', 'month_from', 'month_to'],
|
||||
'sample-deductions' => ['supplier', 'department', 'creator', 'sku_id', 'date_from', 'date_to'],
|
||||
'delivery-unpriced' => ['supplier', 'warehouse', 'purchase', 'barcode', 'express_no', 'date_from', 'date_to'],
|
||||
'dropshipping-express' => ['supplier', 'express_name', 'month_from', 'month_to'],
|
||||
'board-deductions' => ['stock_in_date', 'stock_out_date', 'furniture_factory', 'lamination_factory', 'deduction_type', 'material_name', 'claim_number'],
|
||||
'supplier-payables-report' => ['month', 'supplier', 'checker', 'settlement_method'],
|
||||
};
|
||||
|
||||
return collect($request->only($allowed))
|
||||
->map(static fn (mixed $value): mixed => is_string($value) ? trim($value) : $value)
|
||||
->reject(static fn (mixed $value): bool => $value === '' || $value === null || $value === false)
|
||||
->all();
|
||||
}
|
||||
|
||||
private function dataset(Request $request): string
|
||||
{
|
||||
$dataset = (string) $request->route('dataset');
|
||||
$this->registry->fields($dataset);
|
||||
return $dataset;
|
||||
}
|
||||
|
||||
private function title(string $dataset): string
|
||||
{
|
||||
return [
|
||||
'payment-reconciliation' => '货款对账', 'return-reconciliation' => '退货对账',
|
||||
'invoice-deductions' => '发票扣款', 'compensation-details' => '赔偿明细',
|
||||
'advance-details' => '借支明细', 'sample-deductions' => '样品扣款',
|
||||
'delivery-unpriced' => '送货未定价', 'return-unpriced' => '退货未定价',
|
||||
'other-adjustments' => '其他扣款补款', 'dropshipping-express' => '代发快递',
|
||||
'board-deductions' => '板材扣款', 'supplier-payables-report' => '供应商应付表',
|
||||
][$dataset];
|
||||
}
|
||||
|
||||
private function csvText(mixed $value): string
|
||||
{
|
||||
$value = (string) $value;
|
||||
return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value;
|
||||
}
|
||||
}
|
||||
106
app/Http/Controllers/Business/WithdrawalManagement.php
Normal file
106
app/Http/Controllers/Business/WithdrawalManagement.php
Normal file
@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Business\WithdrawalManagementReport;
|
||||
use App\Business\WithdrawalManagementSpreadsheet;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class WithdrawalManagement extends Controller
|
||||
{
|
||||
private const PREVIEW_CACHE_PREFIX = 'withdrawal-management-import:';
|
||||
|
||||
public function __construct(
|
||||
private readonly WithdrawalManagementReport $report,
|
||||
private readonly WithdrawalManagementSpreadsheet $spreadsheet,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
$this->middleware('business.page:fund-management,withdrawal-management');
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
return $this->spreadsheet->export($this->report->results($request));
|
||||
}
|
||||
|
||||
public function template(): StreamedResponse
|
||||
{
|
||||
return $this->spreadsheet->template();
|
||||
}
|
||||
|
||||
public function importPreview(Request $request): RedirectResponse
|
||||
{
|
||||
$file = $request->validate([
|
||||
'import' => ['required', 'file', 'mimes:xlsx,xls', 'max:10240'],
|
||||
])['import'];
|
||||
$preview = $this->spreadsheet->preview($file, (int) company_id());
|
||||
$token = Str::random(48);
|
||||
Cache::put($this->cacheKey($token), [
|
||||
'company_id' => (int) company_id(),
|
||||
'user_id' => (int) user_id(),
|
||||
'plan' => $preview['plan'],
|
||||
], now()->addMinutes(30));
|
||||
unset($preview['plan']);
|
||||
|
||||
return $this->redirect($request)->with(
|
||||
'withdrawalImportPreview',
|
||||
$preview + ['token' => $token],
|
||||
);
|
||||
}
|
||||
|
||||
public function importCommit(Request $request): RedirectResponse
|
||||
{
|
||||
$token = $request->validate([
|
||||
'preview_token' => ['required', 'string', 'size:48'],
|
||||
])['preview_token'];
|
||||
$payload = Cache::pull($this->cacheKey($token));
|
||||
|
||||
if (! is_array($payload)
|
||||
|| (int) ($payload['company_id'] ?? 0) !== (int) company_id()
|
||||
|| (int) ($payload['user_id'] ?? 0) !== (int) user_id()
|
||||
|| ! is_array($payload['plan'] ?? null)) {
|
||||
return $this->redirect($request)->withErrors([
|
||||
'import' => '导入预览已失效或不属于当前账号,请重新上传并预览。',
|
||||
]);
|
||||
}
|
||||
|
||||
$updated = $this->spreadsheet->commit($payload['plan'], (int) company_id());
|
||||
|
||||
return $this->redirect($request)->with('success', "提现配置导入完成,共更新 {$updated} 家店铺。");
|
||||
}
|
||||
|
||||
private function cacheKey(string $token): string
|
||||
{
|
||||
return self::PREVIEW_CACHE_PREFIX . hash('sha256', $token);
|
||||
}
|
||||
|
||||
private function redirect(Request $request): RedirectResponse
|
||||
{
|
||||
$filters = $this->report->filters($request);
|
||||
$parameters = [
|
||||
'section' => 'fund-management',
|
||||
'page' => 'withdrawal-management',
|
||||
];
|
||||
$queryNames = [
|
||||
'platform' => 'platform',
|
||||
'withdrawalPath' => 'withdrawal_path',
|
||||
'storeEntity' => 'store_entity',
|
||||
'keyword' => 'keyword',
|
||||
'balanceStatus' => 'balance_status',
|
||||
];
|
||||
|
||||
foreach ($queryNames as $filterName => $queryName) {
|
||||
if ($filters[$filterName] !== '') {
|
||||
$parameters[$queryName] = $filters[$filterName];
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('business.pages.show', $parameters);
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ namespace App\Http\Livewire\Business;
|
||||
|
||||
use App\Business\ThirdPartyWarehouseQuoteRepository;
|
||||
use App\Business\ThirdPartyWarehouseStorageFeeCalculator;
|
||||
use App\Exports\ThirdPartyWarehouseStorageDetailExport;
|
||||
use App\Exports\ThirdPartyWarehouseStorageSettlementExport;
|
||||
use App\Models\Business\ThirdPartyWarehouseQuote;
|
||||
use App\Models\Business\ThirdPartyWarehouseStorageSettlement;
|
||||
@ -45,6 +46,15 @@ class ThirdPartyWarehouseQuotes extends Component
|
||||
|
||||
public ?string $settlementError = null;
|
||||
|
||||
public ?string $settlementDetailPeriod = null;
|
||||
|
||||
public string $settlementDetailDate = '';
|
||||
|
||||
/** @var list<string> */
|
||||
public array $settlementDetailDates = [];
|
||||
|
||||
public ?string $settlementDetailMessage = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->effectiveAt = now()->toDateString();
|
||||
@ -130,7 +140,11 @@ class ThirdPartyWarehouseQuotes extends Component
|
||||
'loss_fee' => $summary['loss_fee'],
|
||||
'compensation_fee' => $summary['compensation_fee'],
|
||||
'total_fee' => $summary['total_fee'],
|
||||
'detail_json' => ['daily' => $result['daily'], 'per_sku' => $result['per_sku']],
|
||||
'detail_json' => [
|
||||
'daily' => $result['daily'],
|
||||
'per_sku' => $result['per_sku'],
|
||||
'daily_sku' => $result['daily_sku'],
|
||||
],
|
||||
'calculated_by' => (int) user_id(),
|
||||
],
|
||||
);
|
||||
@ -144,6 +158,7 @@ class ThirdPartyWarehouseQuotes extends Component
|
||||
$this->ensureAuthenticated();
|
||||
|
||||
$settlement = ThirdPartyWarehouseStorageSettlement::query()
|
||||
->where('company_id', (int) company_id())
|
||||
->where('warehouse_id', self::WAREHOUSE_ID)
|
||||
->where('period', $period)
|
||||
->first();
|
||||
@ -154,11 +169,12 @@ class ThirdPartyWarehouseQuotes extends Component
|
||||
return null;
|
||||
}
|
||||
|
||||
$detail = $settlement->detail_json ?: ['daily' => [], 'per_sku' => []];
|
||||
$detail = $settlement->detail_json ?: ['daily' => [], 'per_sku' => [], 'daily_sku' => []];
|
||||
$result = [
|
||||
'summary' => $this->summaryFromSettlement($settlement),
|
||||
'daily' => $detail['daily'] ?? [],
|
||||
'per_sku' => $detail['per_sku'] ?? [],
|
||||
'daily_sku' => $detail['daily_sku'] ?? [],
|
||||
];
|
||||
|
||||
$fileName = $settlement->warehouse_name . '仓储费用_' . $period . '.xlsx';
|
||||
@ -166,11 +182,77 @@ class ThirdPartyWarehouseQuotes extends Component
|
||||
return Excel::download(new ThirdPartyWarehouseStorageSettlementExport($result), $fileName);
|
||||
}
|
||||
|
||||
public function exportSettlementDetails(string $period): ?BinaryFileResponse
|
||||
{
|
||||
$this->ensureAuthenticated();
|
||||
abort_unless(preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period) === 1, 422);
|
||||
|
||||
$settlement = $this->settlementForPeriod($period);
|
||||
if ($settlement === null) {
|
||||
$this->settlementError = '未找到 ' . $period . ' 的核算存档,请先核算。';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$dailySku = ($settlement->detail_json ?: [])['daily_sku'] ?? [];
|
||||
if ($dailySku === []) {
|
||||
$this->showMissingSettlementDetails($period);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$fileName = $settlement->warehouse_name . '每日商品仓储费明细_' . $period . '.xlsx';
|
||||
|
||||
return Excel::download(new ThirdPartyWarehouseStorageDetailExport($dailySku), $fileName);
|
||||
}
|
||||
|
||||
public function viewSettlementDetails(string $period): void
|
||||
{
|
||||
$this->ensureAuthenticated();
|
||||
abort_unless(preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period) === 1, 422);
|
||||
|
||||
$this->settlementDetailPeriod = $period;
|
||||
$this->settlementDetailDate = '';
|
||||
$this->settlementDetailDates = [];
|
||||
$this->settlementDetailMessage = null;
|
||||
|
||||
$settlement = $this->settlementForPeriod($period);
|
||||
if ($settlement === null) {
|
||||
$this->settlementDetailMessage = '未找到 ' . $period . ' 的核算存档。';
|
||||
$this->dispatch('warehouse-settlement-details-opened');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$detail = $settlement->detail_json ?: [];
|
||||
$dailySku = $detail['daily_sku'] ?? [];
|
||||
if ($dailySku === []) {
|
||||
$this->showMissingSettlementDetails($period);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$dates = array_values(array_unique(array_column($dailySku, 'date')));
|
||||
sort($dates);
|
||||
$this->settlementDetailDates = $dates;
|
||||
$this->settlementDetailDate = $dates[0] ?? '';
|
||||
$this->dispatch('warehouse-settlement-details-opened');
|
||||
}
|
||||
|
||||
public function closeSettlementDetails(): void
|
||||
{
|
||||
$this->settlementDetailPeriod = null;
|
||||
$this->settlementDetailDate = '';
|
||||
$this->settlementDetailDates = [];
|
||||
$this->settlementDetailMessage = null;
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
$data = $this->repository()->pageData();
|
||||
|
||||
$history = ThirdPartyWarehouseStorageSettlement::query()
|
||||
->where('company_id', (int) company_id())
|
||||
->where('warehouse_id', self::WAREHOUSE_ID)
|
||||
->orderByDesc('period')
|
||||
->limit(24)
|
||||
@ -184,12 +266,15 @@ class ThirdPartyWarehouseQuotes extends Component
|
||||
'calculated_at' => optional($row->updated_at)->format('Y-m-d H:i'),
|
||||
])
|
||||
->all();
|
||||
$settlementDetail = $this->settlementDetailData();
|
||||
|
||||
return view('business.pages.courier-payables.partials.third-party-warehouse-quotes', [
|
||||
'warehouseQuotes' => $data['rows'],
|
||||
'warehouseQuoteSummary' => $data['summary'],
|
||||
'warehouseQuotesReady' => $data['ready'],
|
||||
'settlementHistory' => $history,
|
||||
'settlementDetailRows' => $settlementDetail['rows'],
|
||||
'settlementDetailSummary' => $settlementDetail['summary'],
|
||||
]);
|
||||
}
|
||||
|
||||
@ -219,6 +304,66 @@ class ThirdPartyWarehouseQuotes extends Component
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细行只参与当前服务端渲染,不进入 Livewire 公共快照,避免日期切换时回传整张表。
|
||||
*
|
||||
* @return array{rows:list<array<string,mixed>>,summary:array<string,float|int>|null}
|
||||
*/
|
||||
private function settlementDetailData(): array
|
||||
{
|
||||
$empty = ['rows' => [], 'summary' => null];
|
||||
|
||||
if ($this->settlementDetailPeriod === null
|
||||
|| ! in_array($this->settlementDetailDate, $this->settlementDetailDates, true)) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$settlement = $this->settlementForPeriod($this->settlementDetailPeriod);
|
||||
if ($settlement === null) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$detail = $settlement->detail_json ?: [];
|
||||
$rows = array_values(array_filter(
|
||||
$detail['daily_sku'] ?? [],
|
||||
fn (array $row): bool => ($row['date'] ?? null) === $this->settlementDetailDate,
|
||||
));
|
||||
usort($rows, static fn (array $left, array $right): int => strcmp(
|
||||
(string) ($left['sku_code'] ?? ''),
|
||||
(string) ($right['sku_code'] ?? ''),
|
||||
));
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'summary' => [
|
||||
'sku_count' => count($rows),
|
||||
'opening' => array_sum(array_column($rows, 'opening')),
|
||||
'net_movement' => array_sum(array_column($rows, 'net_movement')),
|
||||
'closing' => array_sum(array_column($rows, 'closing')),
|
||||
'storage_fee' => array_sum(array_column($rows, 'storage_fee')),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function settlementForPeriod(string $period): ?ThirdPartyWarehouseStorageSettlement
|
||||
{
|
||||
return ThirdPartyWarehouseStorageSettlement::query()
|
||||
->where('company_id', (int) company_id())
|
||||
->where('warehouse_id', self::WAREHOUSE_ID)
|
||||
->where('period', $period)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function showMissingSettlementDetails(string $period): void
|
||||
{
|
||||
$this->settlementPeriod = $period;
|
||||
$this->settlementDetailPeriod = $period;
|
||||
$this->settlementDetailDate = '';
|
||||
$this->settlementDetailDates = [];
|
||||
$this->settlementDetailMessage = $period . ' 的原存档没有每日商品明细,请重新核算该月份后查看和导出。';
|
||||
$this->dispatch('warehouse-settlement-details-opened');
|
||||
}
|
||||
|
||||
/** @return array<string,array<int,string>> */
|
||||
protected function rules(): array
|
||||
{
|
||||
|
||||
83
app/Jobs/Common/InventoryReportExport.php
Normal file
83
app/Jobs/Common/InventoryReportExport.php
Normal file
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Common;
|
||||
|
||||
use App\Abstracts\JobShouldQueue;
|
||||
use App\Business\InventoryWarehouseShortNames;
|
||||
use App\Notifications\Common\ExportFailed;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
|
||||
use Throwable;
|
||||
|
||||
class InventoryReportExport extends JobShouldQueue
|
||||
{
|
||||
/** A complete month can exceed the web request and default queue timeout. */
|
||||
public int $timeout = 600;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
protected int $company_id = 1;
|
||||
|
||||
public function __construct(
|
||||
protected $user,
|
||||
protected array $filters,
|
||||
protected array $columns,
|
||||
protected string $file_name,
|
||||
?int $companyId = null,
|
||||
) {
|
||||
$this->company_id = $companyId ?? 1;
|
||||
$this->onQueue('exports');
|
||||
}
|
||||
|
||||
public function handle(ThirdPartyWarehouseStorageMirrorRepository $repository, InventoryWarehouseShortNames $shortNames): void
|
||||
{
|
||||
$path = storage_path('app/temp/' . $this->file_name);
|
||||
File::ensureDirectoryExists(dirname($path));
|
||||
|
||||
try {
|
||||
$csv = fopen($path, 'wb');
|
||||
if ($csv === false) {
|
||||
throw new \RuntimeException('无法创建进销存报表导出文件。');
|
||||
}
|
||||
|
||||
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
fputcsv($csv, array_column($this->columns, 'label'));
|
||||
$names = $shortNames->forCompany($this->company_id);
|
||||
foreach ($repository->exportMonthlyInventoryReportRows($this->filters) as $row) {
|
||||
$row = $shortNames->fill($row, $names);
|
||||
fputcsv($csv, array_map(function (array $column) use ($row): mixed {
|
||||
$value = data_get($row, $column['name']);
|
||||
|
||||
return $column['name'] === 'product_code'
|
||||
? $this->csvIdentifier($value)
|
||||
: (($column['kind'] ?? null) === 'integer'
|
||||
? ($value === null || $value === '' ? '' : $value)
|
||||
: $this->csvText($value));
|
||||
}, $this->columns));
|
||||
}
|
||||
fclose($csv);
|
||||
|
||||
dispatch(new CreateMediableForExport($this->user, $this->file_name, '进销存报表'));
|
||||
} catch (Throwable $exception) {
|
||||
if (isset($csv) && is_resource($csv)) {
|
||||
fclose($csv);
|
||||
}
|
||||
@unlink($path);
|
||||
report($exception);
|
||||
$this->user->notify(new ExportFailed($exception->getMessage()));
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function csvIdentifier(mixed $value): string
|
||||
{
|
||||
$text = trim((string) ($value ?? ''));
|
||||
|
||||
return $text === '' ? '' : "'" . $text;
|
||||
}
|
||||
|
||||
private function csvText(mixed $value): string
|
||||
{
|
||||
return trim((string) ($value ?? ''));
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,16 @@ class ShowInAdmin
|
||||
'prototype-finance-reports',
|
||||
];
|
||||
|
||||
/**
|
||||
* 这些目录以工作区形式进入:侧边栏只保留一个入口,子页面在页面内的横向页签切换。
|
||||
*/
|
||||
private const WORKSPACE_SECTION_SLUGS = [
|
||||
'supplier-payables',
|
||||
'courier-payables',
|
||||
'supplier-payables-final',
|
||||
'courier-payables-final',
|
||||
];
|
||||
|
||||
/**
|
||||
* Build the business navigation defined by the imported directory workbook.
|
||||
*/
|
||||
@ -52,13 +62,7 @@ class ShowInAdmin
|
||||
|
||||
public static function groupPayables(array $navigation): array
|
||||
{
|
||||
$legacyKeys = ['supplier-payables-final', 'courier-payables-final'];
|
||||
$navigation = array_values(array_filter(
|
||||
$navigation,
|
||||
static fn (array $section): bool => ! in_array($section['slug'], $legacyKeys, true)
|
||||
));
|
||||
|
||||
$keys = ['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 === []) {
|
||||
@ -114,7 +118,7 @@ class ShowInAdmin
|
||||
: $this->sortByTitleLength($section['pages'] ?? []);
|
||||
$order = ($sectionIndex + 1) * 10;
|
||||
|
||||
if (empty($pages) || in_array($section['slug'], ['supplier-payables', 'courier-payables'], true)) {
|
||||
if (empty($pages) || in_array($section['slug'], self::WORKSPACE_SECTION_SLUGS, true)) {
|
||||
$menu->route(
|
||||
'business.pages.show',
|
||||
$section['title'],
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Models\Business;
|
||||
|
||||
use App\Abstracts\Model;
|
||||
use App\Support\EsignErrorMessage;
|
||||
|
||||
class CourierAgreementSignature extends Model
|
||||
{
|
||||
@ -97,11 +98,21 @@ class CourierAgreementSignature extends Model
|
||||
self::STATUS_EXPIRED => '已过期',
|
||||
self::STATUS_REJECTED => '已拒签',
|
||||
self::STATUS_FAILED => '处理失败',
|
||||
self::STATUS_UNKNOWN => '状态未知,请人工核对',
|
||||
self::STATUS_UNKNOWN => $this->hasDefinitiveFailure() ? '处理失败' : '状态未知,请人工核对',
|
||||
default => $this->status,
|
||||
};
|
||||
}
|
||||
|
||||
public function hasDefinitiveFailure(): bool
|
||||
{
|
||||
return EsignErrorMessage::isDefinitiveFailure($this->last_error);
|
||||
}
|
||||
|
||||
public function lastErrorSummary(): ?string
|
||||
{
|
||||
return EsignErrorMessage::sanitize($this->last_error);
|
||||
}
|
||||
|
||||
public function canSend(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_PREPARED, self::STATUS_FAILED], true)
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Models\Business;
|
||||
|
||||
use App\Abstracts\Model;
|
||||
use App\Support\EsignErrorMessage;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CourierQuoteSignature extends Model
|
||||
@ -65,4 +66,9 @@ class CourierQuoteSignature extends Model
|
||||
return in_array($this->status, [self::STATUS_PREPARED, self::STATUS_FAILED], true)
|
||||
&& blank($this->esign_sign_flow_id);
|
||||
}
|
||||
|
||||
public function lastErrorSummary(): ?string
|
||||
{
|
||||
return EsignErrorMessage::sanitize($this->last_error);
|
||||
}
|
||||
}
|
||||
|
||||
44
app/Models/Business/CourierSurchargeAuditLog.php
Normal file
44
app/Models/Business/CourierSurchargeAuditLog.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Business;
|
||||
|
||||
use App\Abstracts\Model;
|
||||
|
||||
class CourierSurchargeAuditLog extends Model
|
||||
{
|
||||
public const ACTION_CREATED = 'created';
|
||||
|
||||
public const ACTION_UPDATED = 'updated';
|
||||
|
||||
public const ACTION_ACTIVATED = 'activated';
|
||||
|
||||
public const ACTION_DEACTIVATED = 'deactivated';
|
||||
|
||||
public const TARGET_QUOTE = 'quote';
|
||||
|
||||
public const TARGET_TYPE = 'type';
|
||||
|
||||
protected $table = 'business_courier_surcharge_audit_logs';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'actor_id',
|
||||
'action',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'target_label',
|
||||
'changes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'company_id' => 'integer',
|
||||
'actor_id' => 'integer',
|
||||
'target_id' => 'integer',
|
||||
'changes' => 'array',
|
||||
];
|
||||
|
||||
public function actor()
|
||||
{
|
||||
return $this->belongsTo(user_model_class(), 'actor_id');
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,7 @@ class CourierSurchargeQuote extends Model
|
||||
'courier',
|
||||
'scope',
|
||||
'reconciler',
|
||||
'supplier',
|
||||
'types',
|
||||
'mode',
|
||||
'standard',
|
||||
|
||||
34
app/Models/Business/InventoryWarehouseNameMapping.php
Normal file
34
app/Models/Business/InventoryWarehouseNameMapping.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Business;
|
||||
|
||||
use App\Abstracts\Model;
|
||||
|
||||
class InventoryWarehouseNameMapping extends Model
|
||||
{
|
||||
protected $table = 'business_inventory_warehouse_name_mappings';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'warehouse_provider',
|
||||
'warehouse',
|
||||
'warehouse_short_name',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'company_id' => 'integer',
|
||||
'created_by' => 'integer',
|
||||
'updated_by' => 'integer',
|
||||
];
|
||||
|
||||
public static function columnLabels(): array
|
||||
{
|
||||
return [
|
||||
'warehouse_provider' => '仓储方',
|
||||
'warehouse' => '仓库',
|
||||
'warehouse_short_name' => '仓库简称',
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Models/Business/JstOrderLabel.php
Normal file
26
app/Models/Business/JstOrderLabel.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Business;
|
||||
|
||||
use App\Abstracts\Model;
|
||||
|
||||
/**
|
||||
* 账单流水数据 › 聚水潭订单标签明细(按标签拆行,一标签一行)。
|
||||
*/
|
||||
class JstOrderLabel extends Model
|
||||
{
|
||||
protected $table = 'jst_order_labels';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'order_no',
|
||||
'shop_name',
|
||||
'ordered_at',
|
||||
'label',
|
||||
'created_from',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'ordered_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
54
app/Models/Business/PlantingDetail.php
Normal file
54
app/Models/Business/PlantingDetail.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Business;
|
||||
|
||||
use App\Abstracts\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 账单流水数据 › 种菜明细表(来源:财务「1-8月种菜」等原始表导入)。
|
||||
*/
|
||||
class PlantingDetail extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'planting_details';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'bill_date',
|
||||
'order_no',
|
||||
'finance_selling_price',
|
||||
'finance_freight',
|
||||
'finance_commission',
|
||||
'jingxi_price_diff',
|
||||
'deduction',
|
||||
'finance_empty_package_fee',
|
||||
'finance_payment_amount',
|
||||
'pieces',
|
||||
'remark',
|
||||
'store',
|
||||
'owner',
|
||||
'account',
|
||||
'type',
|
||||
'creator',
|
||||
'tag',
|
||||
'profit_center',
|
||||
'responsible_platform',
|
||||
'external_id',
|
||||
'created_from',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'bill_date' => 'date',
|
||||
'finance_selling_price' => 'decimal:4',
|
||||
'finance_freight' => 'decimal:4',
|
||||
'finance_commission' => 'decimal:4',
|
||||
'jingxi_price_diff' => 'decimal:4',
|
||||
'deduction' => 'decimal:4',
|
||||
'finance_empty_package_fee' => 'decimal:4',
|
||||
'finance_payment_amount' => 'decimal:4',
|
||||
'pieces' => 'integer',
|
||||
];
|
||||
}
|
||||
@ -23,6 +23,8 @@ class StoreReceivingAccount extends Model
|
||||
'bank_card_last_four' => 'b',
|
||||
'store_id' => 'i',
|
||||
'source_platform' => 'r',
|
||||
'withdrawal_amount' => 'm',
|
||||
'withdrawal_balance_date' => 'd',
|
||||
];
|
||||
|
||||
protected $table = 'business_store_receiving_accounts';
|
||||
@ -35,6 +37,7 @@ class StoreReceivingAccount extends Model
|
||||
'payment_password', 'payment_verification_method', 'financial_account', 'financial_password',
|
||||
'payment_verification_method_secondary', 'withdrawal_path', 'withdrawal_account_name',
|
||||
'bank_card_last_four', 'store_id', 'source_platform',
|
||||
'withdrawal_amount', 'withdrawal_balance_date',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@ -213,6 +216,26 @@ class StoreReceivingAccount extends Model
|
||||
$this->setMeta('source_platform', $this->clean($value));
|
||||
}
|
||||
|
||||
public function getWithdrawalAmountAttribute(): ?string
|
||||
{
|
||||
return $this->meta('withdrawal_amount');
|
||||
}
|
||||
|
||||
public function setWithdrawalAmountAttribute($value): void
|
||||
{
|
||||
$this->setMeta('withdrawal_amount', $this->clean($value));
|
||||
}
|
||||
|
||||
public function getWithdrawalBalanceDateAttribute(): ?string
|
||||
{
|
||||
return $this->meta('withdrawal_balance_date');
|
||||
}
|
||||
|
||||
public function setWithdrawalBalanceDateAttribute($value): void
|
||||
{
|
||||
$this->setMeta('withdrawal_balance_date', $this->clean($value));
|
||||
}
|
||||
|
||||
private function clean($value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
@ -184,6 +184,20 @@ final class EsignClient
|
||||
]);
|
||||
}
|
||||
|
||||
public function downloadUploadedPdf(string $fileId): string
|
||||
{
|
||||
$data = $this->request('GET', "/v3/files/{$fileId}");
|
||||
$downloadUrl = (string) ($data['fileDownloadUrl']
|
||||
?? $data['downloadUrl']
|
||||
?? data_get($data, 'files.0.downloadUrl', ''));
|
||||
|
||||
if ($downloadUrl === '') {
|
||||
throw new RuntimeException('E 签宝未返回历史原文件下载地址。');
|
||||
}
|
||||
|
||||
return $this->download($downloadUrl);
|
||||
}
|
||||
|
||||
public function download(string $url): string
|
||||
{
|
||||
$response = Http::timeout($this->timeout())->get($url);
|
||||
|
||||
55
app/Support/CourierAgreementFileArchive.php
Normal file
55
app/Support/CourierAgreementFileArchive.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Business\CourierAgreementSignature;
|
||||
use App\Services\Esign\EsignClient;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
|
||||
final class CourierAgreementFileArchive
|
||||
{
|
||||
public function sourcePath(CourierAgreementSignature $signature): string
|
||||
{
|
||||
$path = trim((string) $signature->source_pdf_path);
|
||||
if ($path === '') {
|
||||
throw new RuntimeException('该历史任务没有保存签署原文路径,无法下载。');
|
||||
}
|
||||
|
||||
$disk = Storage::disk('uploads');
|
||||
if ($disk->exists($path)) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
if (blank($signature->esign_file_id)) {
|
||||
throw new RuntimeException('本地历史文件缺失,且该任务未成功上传至 E 签宝,无法自动恢复。');
|
||||
}
|
||||
|
||||
$expectedHash = strtolower(trim((string) $signature->source_pdf_sha256));
|
||||
if (preg_match('/^[a-f0-9]{64}$/', $expectedHash) !== 1) {
|
||||
throw new RuntimeException('本地历史文件缺失,且没有可用于校验的原文件摘要,已停止自动恢复。');
|
||||
}
|
||||
|
||||
$bytes = app(EsignClient::class)->downloadUploadedPdf((string) $signature->esign_file_id);
|
||||
if (! str_starts_with($bytes, '%PDF-')) {
|
||||
throw new RuntimeException('E 签宝返回的历史原文件不是有效 PDF,已停止自动恢复。');
|
||||
}
|
||||
|
||||
if (! hash_equals($expectedHash, hash('sha256', $bytes))) {
|
||||
throw new RuntimeException('E 签宝历史原文件与系统留存摘要不一致,已停止自动恢复。');
|
||||
}
|
||||
|
||||
if (! $disk->put($path, $bytes)) {
|
||||
throw new RuntimeException('历史原文件无法写回私有业务存储。');
|
||||
}
|
||||
|
||||
$storedBytes = $disk->get($path);
|
||||
if (! str_starts_with($storedBytes, '%PDF-')
|
||||
|| ! hash_equals($expectedHash, hash('sha256', $storedBytes))) {
|
||||
$disk->delete($path);
|
||||
throw new RuntimeException('历史原文件写回后校验失败,已移除不完整文件。');
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@ -3,17 +3,26 @@
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Business\CourierAgreementSignature;
|
||||
use Illuminate\Support\Carbon;
|
||||
use RuntimeException;
|
||||
|
||||
final class CourierAgreementPdf
|
||||
{
|
||||
public function render(CourierAgreementSignature $signature): string
|
||||
{
|
||||
$payload = $signature->payload ?? [];
|
||||
$signatureDate = (string) ($payload['signature_date'] ?? $payload['entity_proof_date'] ?? $payload['shared_proof_date'] ?? '');
|
||||
try {
|
||||
$signatureDate = $signatureDate !== '' ? Carbon::parse($signatureDate)->format('Y年n月j日') : '待填写';
|
||||
} catch (\Throwable) {
|
||||
$signatureDate = '待填写';
|
||||
}
|
||||
$html = view('business.pdf.courier-agreement', [
|
||||
'signature' => $signature,
|
||||
'type' => $signature->document_type,
|
||||
'title' => $this->title($signature),
|
||||
'payload' => $signature->payload ?? [],
|
||||
'payload' => $payload,
|
||||
'signatureDate' => $signatureDate,
|
||||
'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')),
|
||||
])->render();
|
||||
|
||||
|
||||
@ -96,6 +96,10 @@ final class CourierAgreementSignatureService
|
||||
$bytes = Storage::disk('uploads')->get($signature->source_pdf_path);
|
||||
$fileName = 'agreement-' . $signature->business_no . '.pdf';
|
||||
$fileId = $client->uploadPdf($bytes, $fileName);
|
||||
$signature->update([
|
||||
'esign_file_id' => $fileId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
$signer = [
|
||||
'flow_title' => $this->safeTitle($signature->company_full_name . ' ' . $signature->documentLabel()),
|
||||
'organization_name' => $signature->organization_name,
|
||||
@ -120,9 +124,12 @@ final class CourierAgreementSignatureService
|
||||
|
||||
return $signature->fresh();
|
||||
} catch (Throwable $exception) {
|
||||
$error = EsignErrorMessage::sanitize($exception->getMessage(), 1000) ?? 'E 签宝处理失败。';
|
||||
$signature->update([
|
||||
'status' => $flowCreationStarted ? CourierAgreementSignature::STATUS_UNKNOWN : CourierAgreementSignature::STATUS_FAILED,
|
||||
'last_error' => mb_substr((string) preg_replace('/[[:cntrl:]]+/u', ' ', $exception->getMessage()), 0, 1000),
|
||||
'status' => $flowCreationStarted && ! EsignErrorMessage::isDefinitiveFailure($exception->getMessage())
|
||||
? CourierAgreementSignature::STATUS_UNKNOWN
|
||||
: CourierAgreementSignature::STATUS_FAILED,
|
||||
'last_error' => $error,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
throw ValidationException::withMessages(['signature' => $signature->last_error]);
|
||||
@ -278,15 +285,12 @@ final class CourierAgreementSignatureService
|
||||
$payload = $signature->payload ?? [];
|
||||
|
||||
if (in_array($signature->document_type, ['account-proof', 'account-change'], true)) {
|
||||
$name = $signature->organization_name;
|
||||
$isOrganization = $this->looksLikeOrganization($name);
|
||||
$isPerson = ($payload['counterparty_signer_type'] ?? 'organization') === 'person';
|
||||
|
||||
return [[
|
||||
'signer_type' => $isOrganization ? 1 : 0,
|
||||
'organization_name' => $isOrganization ? $name : null,
|
||||
'operator_name' => $isOrganization
|
||||
? ($signature->operator_name ?: $signature->courier_name)
|
||||
: $name,
|
||||
'signer_type' => $isPerson ? 0 : 1,
|
||||
'organization_name' => $isPerson ? null : $signature->organization_name,
|
||||
'operator_name' => $isPerson ? ($signature->operator_name ?: $signature->organization_name) : $signature->operator_name,
|
||||
'operator_account' => $signature->operator_account,
|
||||
]];
|
||||
}
|
||||
@ -345,11 +349,6 @@ final class CourierAgreementSignatureService
|
||||
return [$primary];
|
||||
}
|
||||
|
||||
private function looksLikeOrganization(string $name): bool
|
||||
{
|
||||
return preg_match('/公司|有限公司|有限责任|企业|集团|合作社|事务所|中心|厂/u', $name) === 1;
|
||||
}
|
||||
|
||||
private function safeTitle(string $title): string
|
||||
{
|
||||
$title = preg_replace('/[^\p{Han}\p{L}\p{N}_ -]/u', '-', $title) ?: '协议签署';
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Business\CourierQuoteVersion;
|
||||
use Illuminate\Support\Carbon;
|
||||
use RuntimeException;
|
||||
|
||||
final class CourierQuotePdf
|
||||
@ -11,9 +12,10 @@ final class CourierQuotePdf
|
||||
{
|
||||
}
|
||||
|
||||
public function render(CourierQuoteVersion $version): string
|
||||
public function render(CourierQuoteVersion $version, ?string $signatureDate = null): string
|
||||
{
|
||||
$data = $this->confirmation->data($version);
|
||||
$signatureDate = Carbon::parse($signatureDate ?: now())->format('Y年n月j日');
|
||||
$html = view('business.pdf.courier-quote', [
|
||||
'version' => $version,
|
||||
'change' => $version->change,
|
||||
@ -23,6 +25,7 @@ final class CourierQuotePdf
|
||||
'expiryDate' => $data['expiry_date'],
|
||||
'faceFee' => $data['face_fee'],
|
||||
'bubbleCoefficient' => $data['bubble_coefficient'],
|
||||
'signatureDate' => $signatureDate,
|
||||
'generatedAt' => now(),
|
||||
'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')),
|
||||
])->render();
|
||||
|
||||
@ -18,11 +18,11 @@ final class CourierQuoteSignatureService
|
||||
{
|
||||
}
|
||||
|
||||
public function prepareVersion(CourierQuoteVersion $version, int $userId): CourierQuoteSignature
|
||||
public function prepareVersion(CourierQuoteVersion $version, int $userId, ?string $signatureDate = null): CourierQuoteSignature
|
||||
{
|
||||
$existing = $version->signature;
|
||||
|
||||
if ($existing && $this->sourcePdfIsIntact($existing)) {
|
||||
if ($signatureDate === null && $existing && $this->sourcePdfIsIntact($existing)) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ final class CourierQuoteSignatureService
|
||||
]);
|
||||
}
|
||||
|
||||
$bytes = $this->pdf->render($version);
|
||||
$bytes = $this->pdf->render($version, $signatureDate);
|
||||
$path = $this->path($version, 'source.pdf');
|
||||
$sha256 = hash('sha256', $bytes);
|
||||
|
||||
|
||||
32
app/Support/EsignErrorMessage.php
Normal file
32
app/Support/EsignErrorMessage.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class EsignErrorMessage
|
||||
{
|
||||
public static function sanitize(?string $message, int $limit = 240): ?string
|
||||
{
|
||||
$message = trim((string) preg_replace('/[[:cntrl:]]+/u', ' ', (string) $message));
|
||||
if ($message === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$message = (string) preg_replace('/(?<!\d)(1[3-9]\d)\d{4}(\d{4})(?!\d)/u', '$1****$2', $message);
|
||||
$message = (string) preg_replace_callback(
|
||||
'/(?<![A-Z0-9._%+\-])([A-Z0-9._%+\-]+)@([A-Z0-9.\-]+\.[A-Z]{2,})(?![A-Z0-9._%+\-])/iu',
|
||||
static fn (array $matches): string => mb_substr($matches[1], 0, 1) . '***@' . $matches[2],
|
||||
$message,
|
||||
);
|
||||
$message = (string) preg_replace('/(?<!\d)(\d{6})\d{8}(\d{3}[0-9Xx])(?!\d)/u', '$1********$2', $message);
|
||||
|
||||
return mb_substr($message, 0, $limit);
|
||||
}
|
||||
|
||||
public static function isDefinitiveFailure(?string $message): bool
|
||||
{
|
||||
$message = (string) $message;
|
||||
|
||||
return preg_match('/E\s*签宝接口返回业务错误/u', $message) === 1
|
||||
|| preg_match('/E\s*签宝接口请求失败(HTTP\s+4\d{2})/iu', $message) === 1;
|
||||
}
|
||||
}
|
||||
245
app/Support/ReturnPickupFreightCalculator.php
Normal file
245
app/Support/ReturnPickupFreightCalculator.php
Normal file
@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* 用逆向物流报价计算退货取件明细的实际运费。
|
||||
*
|
||||
* 报价来源与「快递应付 → 报价管理 → 逆向物流报价」完全同一份快照:
|
||||
* 速寄递 → 申通退货、德邦 → 常州德邦逆向、中通快运 → 中通快运逆向。
|
||||
*/
|
||||
class ReturnPickupFreightCalculator
|
||||
{
|
||||
private const CARRIER_MAP = [
|
||||
'速寄递' => '申通',
|
||||
'申通' => '申通',
|
||||
'德邦' => '德邦',
|
||||
'中通快运' => '中通快运',
|
||||
];
|
||||
|
||||
private const PROVINCES = [
|
||||
'北京', '天津', '上海', '重庆', '河北', '山西', '辽宁', '吉林', '黑龙江', '江苏', '浙江', '安徽',
|
||||
'福建', '江西', '山东', '河南', '湖北', '湖南', '广东', '海南', '四川', '贵州', '云南', '陕西',
|
||||
'甘肃', '青海', '内蒙古', '广西', '西藏', '宁夏', '新疆',
|
||||
];
|
||||
|
||||
/** @var array<string,array<string,array<string,mixed>>>|null */
|
||||
private ?array $rateIndex = null;
|
||||
|
||||
/** @var array<string,array<string,mixed>>|null */
|
||||
private ?array $rules = null;
|
||||
|
||||
public function __construct(private readonly ReverseLogisticsQuoteSnapshot $snapshot)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* carrier:?string,covered:bool,reason:?string,billable:?float,total:?float,surcharge:float,
|
||||
* total_with_surcharge:?float,formula:?string,note:?string,surcharge_note:?string,source:?string,
|
||||
* settlement:?float,gross_weight:?float,volume_weight:?float,base_amount:?float,increment_amount:?float,
|
||||
* process:?string
|
||||
* }
|
||||
*/
|
||||
public function calculate(
|
||||
?string $expressCompany,
|
||||
?string $originProvince,
|
||||
?string $receiverAddress,
|
||||
float $grossWeight,
|
||||
?float $length = null,
|
||||
?float $width = null,
|
||||
?float $height = null,
|
||||
): array {
|
||||
$carrier = self::CARRIER_MAP[trim((string) $expressCompany)] ?? null;
|
||||
|
||||
if ($carrier === null) {
|
||||
return $this->missing('该快递公司不在逆向物流报价范围内');
|
||||
}
|
||||
|
||||
$length = max((float) $length, 0);
|
||||
$width = max((float) $width, 0);
|
||||
$height = max((float) $height, 0);
|
||||
$volumeWeight = ($length > 0 && $width > 0 && $height > 0)
|
||||
? round($length * $width * $height / 6000, 4)
|
||||
: 0.0;
|
||||
$settlement = round(max($grossWeight, $volumeWeight), 4);
|
||||
|
||||
if ($settlement <= 0) {
|
||||
return $this->missing('缺少重量与尺寸,无法计算实际运费');
|
||||
}
|
||||
|
||||
$origin = ReverseLogisticsQuoteSnapshot::provinceKey((string) $originProvince);
|
||||
$destination = ReverseLogisticsQuoteSnapshot::provinceKey(self::destinationProvince((string) $receiverAddress));
|
||||
$this->load();
|
||||
$bucket = $this->rateIndex[$carrier][$origin . '|' . $destination] ?? [];
|
||||
|
||||
if ($bucket === []) {
|
||||
return $this->missing(sprintf('逆向物流报价未覆盖 %s→%s', $origin !== '' ? $origin : '(未知出发省)', $destination !== '' ? $destination : '(未知到达省)'));
|
||||
}
|
||||
|
||||
$rule = $this->rules[$carrier] ?? [];
|
||||
$mode = (string) ($rule['pricing_mode'] ?? '');
|
||||
$maxSide = max($length, $width, $height);
|
||||
$sideSum = $length + $width + $height;
|
||||
$billable = 0.0;
|
||||
$total = 0.0;
|
||||
$surcharge = 0.0;
|
||||
$surchargeNote = null;
|
||||
$formula = '';
|
||||
$note = '';
|
||||
$source = '';
|
||||
$baseAmount = 0.0;
|
||||
$incrementAmount = 0.0;
|
||||
$process = [];
|
||||
$number = static function (float $value): string {
|
||||
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
||||
};
|
||||
|
||||
if ($volumeWeight > 0) {
|
||||
$process[] = '重量取大:max(毛重 ' . $number($grossWeight) . 'kg,体积重 ' . $number($volumeWeight) . 'kg) = ' . $number($settlement) . 'kg';
|
||||
} else {
|
||||
$process[] = '未填长宽高,按毛重 ' . $number($grossWeight) . 'kg 作为结算重量';
|
||||
}
|
||||
|
||||
if ($mode === 'first_weight_increment') {
|
||||
$rate = $bucket['base'] ?? null;
|
||||
if ($rate === null || ! ($rate['available'] ?? false)) {
|
||||
return $this->missing('该线路无可用报价');
|
||||
}
|
||||
|
||||
$baseAmount = (float) $rate['base_amount'];
|
||||
$incrementAmount = (float) $rate['increment_amount'];
|
||||
$billable = (float) max(1, (int) ceil($settlement));
|
||||
$total = $baseAmount + $incrementAmount * ($billable - 1);
|
||||
$formula = '面单 ' . $rate['base_amount'] . ' + 续重 ' . $rate['increment_amount'] . ' ×(' . $billable . ' − 1)';
|
||||
$note = '续重按结算重量取整(原表备注 3)';
|
||||
$source = (string) ($rate['source'] ?? '');
|
||||
$process[] = '续重向上取整:' . $number($settlement) . 'kg → ' . $number($billable) . 'kg';
|
||||
$process[] = '面单 ' . $number($baseAmount) . ' 元 + 续重 ' . $number($incrementAmount) . ' 元/kg ×(' . $number($billable) . ' − 1)= ' . $number($total) . ' 元';
|
||||
} elseif (($rule['weight_bands'] ?? []) !== []) {
|
||||
$billable = (float) max(15, (int) ceil($settlement));
|
||||
$band = $billable > 30 ? '31-60kg' : '0-30kg';
|
||||
$rate = $bucket[$band] ?? ($bucket['base'] ?? null);
|
||||
|
||||
// 重量段版式以“重量段费率”判断可用性:面单费存在但费率为“/”时视为无报价。
|
||||
if ($rate === null || ($rate['increment_amount'] ?? null) === null) {
|
||||
return $this->missing('该线路重量段无报价');
|
||||
}
|
||||
|
||||
$baseAmount = (float) $rate['base_amount'];
|
||||
$incrementAmount = (float) $rate['increment_amount'];
|
||||
$total = $baseAmount + $incrementAmount * $billable;
|
||||
$formula = '面单 ' . $rate['base_amount'] . ' + 费率 ' . $rate['increment_amount'] . ' × ' . $billable . '(' . $band . ')';
|
||||
$note = '不足 15kg 按 15kg 计费并向上取整(原表备注 2)';
|
||||
$process[] = '不足 15kg 按 15kg 计并向上取整:' . $number($settlement) . 'kg → ' . $number($billable) . 'kg';
|
||||
$process[] = '面单 ' . $number($baseAmount) . ' 元 + 费率 ' . $number($incrementAmount) . ' 元/kg × ' . $number($billable) . 'kg(' . $band . ')= ' . $number($total) . ' 元';
|
||||
|
||||
if ($billable > 60 || $maxSide > 200) {
|
||||
$surcharge = max(20, $billable);
|
||||
$surchargeNote = '超长超重附加费 ' . round($surcharge, 2) . ' 元(最长边>2m 或 计费重量>60kg,1 元/kg 最低 20 元)';
|
||||
}
|
||||
|
||||
$source = (string) ($rate['source'] ?? '');
|
||||
} else {
|
||||
$rate = $bucket['base'] ?? null;
|
||||
if ($rate === null || ! ($rate['available'] ?? false)) {
|
||||
return $this->missing('该线路无可用报价');
|
||||
}
|
||||
|
||||
$baseAmount = (float) $rate['base_amount'];
|
||||
$incrementAmount = (float) $rate['increment_amount'];
|
||||
$billable = round($settlement, 2);
|
||||
$total = $baseAmount + $incrementAmount * $billable;
|
||||
$formula = '面单 ' . $rate['base_amount'] . ' + 费率 ' . $rate['increment_amount'] . ' × ' . $billable;
|
||||
$note = '按原表「面单+重量×费率」';
|
||||
$process[] = '按结算重量计费(不取整):' . $number($billable) . 'kg';
|
||||
$process[] = '面单 ' . $number($baseAmount) . ' 元 + 费率 ' . $number($incrementAmount) . ' 元/kg × ' . $number($billable) . 'kg = ' . $number($total) . ' 元';
|
||||
|
||||
if ($maxSide > 160 || $sideSum > 250) {
|
||||
$surcharge = 7.0;
|
||||
$surchargeNote = '单边>1.6m 或 三边>2.5m,加收 7 元/单(原表备注 1)';
|
||||
}
|
||||
|
||||
$source = (string) ($rate['source'] ?? '');
|
||||
}
|
||||
|
||||
if ($surcharge > 0) {
|
||||
$process[] = '加收单列:' . $number($surcharge) . ' 元(' . $surchargeNote . ')';
|
||||
}
|
||||
|
||||
return [
|
||||
'carrier' => $carrier,
|
||||
'covered' => true,
|
||||
'reason' => null,
|
||||
'billable' => $billable,
|
||||
'total' => round($total, 2),
|
||||
'surcharge' => round($surcharge, 2),
|
||||
'total_with_surcharge' => round($total + $surcharge, 2),
|
||||
'formula' => $formula,
|
||||
'note' => $note,
|
||||
'surcharge_note' => $surchargeNote,
|
||||
'source' => $source,
|
||||
'settlement' => $settlement,
|
||||
'gross_weight' => round($grossWeight, 4),
|
||||
'volume_weight' => round($volumeWeight, 4),
|
||||
'base_amount' => $baseAmount,
|
||||
'increment_amount' => $incrementAmount,
|
||||
'process' => implode(';', $process),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{carrier:?string,covered:bool,reason:string,billable:?float,total:?float,surcharge:float,total_with_surcharge:?float,formula:?string,note:?string,surcharge_note:?string,source:?string,settlement:?float,gross_weight:?float,volume_weight:?float,base_amount:?float,increment_amount:?float,process:?string} */
|
||||
private function missing(string $reason): array
|
||||
{
|
||||
return [
|
||||
'carrier' => null,
|
||||
'covered' => false,
|
||||
'reason' => $reason,
|
||||
'billable' => null,
|
||||
'total' => null,
|
||||
'surcharge' => 0.0,
|
||||
'total_with_surcharge' => null,
|
||||
'formula' => null,
|
||||
'note' => null,
|
||||
'surcharge_note' => null,
|
||||
'source' => null,
|
||||
'settlement' => null,
|
||||
'gross_weight' => null,
|
||||
'volume_weight' => null,
|
||||
'base_amount' => null,
|
||||
'increment_amount' => null,
|
||||
'process' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function load(): void
|
||||
{
|
||||
if ($this->rateIndex !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->rateIndex = [];
|
||||
$this->rules = [];
|
||||
$payload = $this->snapshot->load();
|
||||
$this->rules = collect($payload['rate_groups'] ?? [])->keyBy('carrier')->all();
|
||||
|
||||
foreach ($payload['rates'] ?? [] as $rate) {
|
||||
$key = ($rate['origin_key'] ?? '') . '|' . ($rate['destination_key'] ?? '');
|
||||
$this->rateIndex[$rate['carrier']][$key][$rate['weight_band'] ?: 'base'] = $rate;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从收货地址里取出发往省份(用于匹配报价的到达省)。 */
|
||||
public static function destinationProvince(string $address): string
|
||||
{
|
||||
$address = trim($address);
|
||||
|
||||
foreach (self::PROVINCES as $province) {
|
||||
if (str_starts_with($address, $province)) {
|
||||
return $province;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
28
app/Support/ReturnPickupOrderStatus.php
Normal file
28
app/Support/ReturnPickupOrderStatus.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/** 工作平台退货取件的订单状态码与中文标签(与工作平台页面一致)。 */
|
||||
final class ReturnPickupOrderStatus
|
||||
{
|
||||
public const LABELS = [
|
||||
'WaitExpOrder' => '待快递下单',
|
||||
'WaitExpAccept' => '待快递接单',
|
||||
'ExpAccepted' => '快递已接单',
|
||||
'ExpCanceled' => '快递已取消订单',
|
||||
'CustomerServiceConfirmed' => '客服已确认',
|
||||
'WaitServerComfrim' => '待客服确认',
|
||||
'TicketTimeoutClose' => '工单超时关闭',
|
||||
];
|
||||
|
||||
public static function label(?string $code): string
|
||||
{
|
||||
$code = trim((string) $code);
|
||||
|
||||
if ($code === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return self::LABELS[$code] ?? $code;
|
||||
}
|
||||
}
|
||||
712
app/Support/ReverseLogisticsQuoteSnapshot.php
Normal file
712
app/Support/ReverseLogisticsQuoteSnapshot.php
Normal file
@ -0,0 +1,712 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use JsonException;
|
||||
|
||||
/**
|
||||
* 逆向物流报价快照:从本机私有 JSON(由逆向物流报价.xlsx 生成)读取报价版本,
|
||||
* 并把三种原表版式(矩阵、单一到达省、重量段)拆分为可用于运费计算的运价明细。
|
||||
*
|
||||
* 与 QuoteManagementSnapshot 同构:只读展示,不重新计算金额。
|
||||
*/
|
||||
final class ReverseLogisticsQuoteSnapshot
|
||||
{
|
||||
public const RELATIVE_PATH = 'app/private/quote-management/reverse-logistics-quotes.json';
|
||||
|
||||
/** 出发省份 × 到达省份矩阵,单元格为面单价格 + 续重价格(如申通退货)。 */
|
||||
private const LAYOUT_MATRIX = 'matrix';
|
||||
|
||||
/** 单一到达省份,每行一个出发省份(如常州德邦逆向)。 */
|
||||
private const LAYOUT_SINGLE_DESTINATION = 'single-destination';
|
||||
|
||||
/** 单一到达仓,每行一个出发省份,按重量段给出费率(如中通快运逆向)。 */
|
||||
private const LAYOUT_WEIGHT_BAND = 'weight-band';
|
||||
|
||||
public function __construct(private readonly ?string $path = null)
|
||||
{
|
||||
}
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
$path = $this->path ?? storage_path(self::RELATIVE_PATH);
|
||||
|
||||
if (! is_file($path)) {
|
||||
return $this->emptySnapshot('missing', '尚未导入本机逆向物流报价文件。');
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return $this->emptySnapshot('invalid', '本机逆向物流报价快照无法读取,请重新导入源文件。');
|
||||
}
|
||||
|
||||
if (! is_array($payload) || ! is_array($payload['sheets'] ?? null)) {
|
||||
return $this->emptySnapshot('invalid', '本机逆向物流报价快照结构不完整,请重新导入源文件。');
|
||||
}
|
||||
|
||||
$quotes = [];
|
||||
|
||||
foreach (array_values($payload['sheets']) as $position => $sheet) {
|
||||
if (! is_array($sheet) || trim((string) ($sheet['name'] ?? '')) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$quotes[] = $this->normalizeSheet($sheet, $position);
|
||||
}
|
||||
|
||||
$rates = [];
|
||||
$rateGroups = [];
|
||||
$unparsedSheets = [];
|
||||
$originKeys = [];
|
||||
$destinationKeys = [];
|
||||
|
||||
foreach ($quotes as $quote) {
|
||||
$extracted = $this->extractRates($quote);
|
||||
|
||||
if ($extracted === null) {
|
||||
$unparsedSheets[] = $quote['sheet_name'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$rateGroups[] = $extracted['rule'];
|
||||
|
||||
foreach ($extracted['rates'] as $rate) {
|
||||
$rates[] = $rate;
|
||||
|
||||
if (! in_array($rate['origin_key'], $originKeys, true)) {
|
||||
$originKeys[] = $rate['origin_key'];
|
||||
}
|
||||
|
||||
if (! in_array($rate['destination_key'], $destinationKeys, true)) {
|
||||
$destinationKeys[] = $rate['destination_key'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'state' => 'ready',
|
||||
'message' => null,
|
||||
'source_file' => basename(str_replace('\\', '/', (string) ($payload['sourcePath'] ?? '逆向物流报价.xlsx'))),
|
||||
'generated_at' => (string) ($payload['generatedAt'] ?? ''),
|
||||
'quotes' => $quotes,
|
||||
'rates' => $rates,
|
||||
'rate_groups' => $rateGroups,
|
||||
'unparsed_sheets' => $unparsedSheets,
|
||||
'provinces' => [
|
||||
'origins' => $originKeys,
|
||||
'destinations' => $destinationKeys,
|
||||
],
|
||||
'summary' => [
|
||||
'quote_count' => count($quotes),
|
||||
'carrier_count' => count(array_unique(array_column($quotes, 'carrier'))),
|
||||
'dated_count' => count(array_filter($quotes, fn (array $quote) => $quote['effective_date'] !== null)),
|
||||
'formula_count' => array_sum(array_column($quotes, 'formula_count')),
|
||||
],
|
||||
'rate_summary' => [
|
||||
'rate_count' => count($rates),
|
||||
'carrier_count' => count($rateGroups),
|
||||
'origin_count' => count(array_unique(array_column($rates, 'origin'))),
|
||||
'destination_count' => count(array_unique(array_column($rates, 'destination'))),
|
||||
'weight_band_count' => count(array_unique(array_filter(
|
||||
array_column($rates, 'weight_band'),
|
||||
static fn ($band) => $band !== null && $band !== ''
|
||||
))),
|
||||
'available_count' => count(array_filter($rates, static fn (array $rate) => $rate['available'])),
|
||||
'missing_count' => count(array_filter($rates, static fn (array $rate) => ! $rate['available'])),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一个报价工作表拆分为运价明细;无法识别版式时返回 null。
|
||||
*
|
||||
* @return array{rule:array<string,mixed>,rates:list<array<string,mixed>>}|null
|
||||
*/
|
||||
private function extractRates(array $quote): ?array
|
||||
{
|
||||
$values = $quote['values'];
|
||||
$layout = $this->detectLayout($values);
|
||||
|
||||
if ($layout === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$notes = $this->noteLines($values, $layout);
|
||||
$context = [
|
||||
'carrier' => $quote['carrier'],
|
||||
'sheet_name' => $quote['sheet_name'],
|
||||
'quote_type' => $quote['quote_type'],
|
||||
'effective_date' => $quote['effective_date'],
|
||||
'layout' => $layout,
|
||||
'notes' => $notes,
|
||||
];
|
||||
|
||||
return match ($layout) {
|
||||
self::LAYOUT_MATRIX => $this->extractMatrixRates($values, $context),
|
||||
self::LAYOUT_SINGLE_DESTINATION => $this->extractSingleDestinationRates($values, $context),
|
||||
self::LAYOUT_WEIGHT_BAND => $this->extractWeightBandRates($values, $context),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/** @param list<array<int,mixed>> $values */
|
||||
private function detectLayout(array $values): ?string
|
||||
{
|
||||
$header = $values[1] ?? [];
|
||||
|
||||
foreach ($header as $index => $label) {
|
||||
if (! is_string($label)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($index >= 3 && preg_match('/^\d+\s*-\s*\d+\s*kg$/u', trim($label)) === 1) {
|
||||
return self::LAYOUT_WEIGHT_BAND;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($header[0], $header[1])
|
||||
&& trim((string) $header[0]) === '出发省份'
|
||||
&& trim((string) $header[1]) === '到达省份') {
|
||||
return self::LAYOUT_MATRIX;
|
||||
}
|
||||
|
||||
if (isset($header[1]) && is_string($header[1]) && str_starts_with(trim($header[1]), '到达省份')) {
|
||||
return self::LAYOUT_SINGLE_DESTINATION;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 矩阵版式:列方向按“到达省份”成组,每组两列(面单价格、续重价格),行方向为出发省份。
|
||||
*
|
||||
* @param list<array<int,mixed>> $values
|
||||
* @param array<string,mixed> $context
|
||||
* @return array{rule:array<string,mixed>,rates:list<array<string,mixed>>}
|
||||
*/
|
||||
private function extractMatrixRates(array $values, array $context): array
|
||||
{
|
||||
$destinations = [];
|
||||
$subHeader = $values[2] ?? [];
|
||||
|
||||
foreach ($subHeader as $index => $label) {
|
||||
if (! is_string($label) || trim($label) !== '面单价格') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim((string) ($values[1][$index] ?? ''));
|
||||
|
||||
if ($name === '' || $name === '到达省份') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$destinations[$index] = ['name' => $name, 'increment' => $index + 1];
|
||||
}
|
||||
|
||||
$rates = [];
|
||||
$position = 0;
|
||||
|
||||
foreach ($values as $rowIndex => $row) {
|
||||
if ($rowIndex < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$origin = trim((string) ($row[1] ?? ''));
|
||||
|
||||
if ($origin === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($destinations as $baseIndex => $destination) {
|
||||
$position++;
|
||||
$baseAmount = $this->amount($row[$baseIndex] ?? null);
|
||||
$incrementAmount = $this->amount($row[$destination['increment']] ?? null);
|
||||
|
||||
$rates[] = $this->rateRow($context, $position, [
|
||||
'origin' => $origin,
|
||||
'destination' => $destination['name'],
|
||||
'weight_band' => null,
|
||||
'base_amount' => $baseAmount,
|
||||
'increment_amount' => $incrementAmount,
|
||||
'source' => $this->cellRef($baseIndex, $rowIndex) . ':' . $this->cellRef($destination['increment'], $rowIndex),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'rule' => $this->rateRule($context, [
|
||||
'pricing_mode' => 'first_weight_increment',
|
||||
'pricing_label' => '首重 + 续重 × (结算重量 - 1)',
|
||||
'base_label' => '面单价格(首重 1kg)',
|
||||
'increment_label' => '续重价格',
|
||||
'route_label' => sprintf('行=出发省份,列=到达省份(%d 个到达省)', count($destinations)),
|
||||
'weight_bands' => [],
|
||||
'rates' => $rates,
|
||||
]),
|
||||
'rates' => $rates,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 单一到达省版式:A 列出发省份,随后两列为面单价格、续重价格。
|
||||
*
|
||||
* @param list<array<int,mixed>> $values
|
||||
* @param array<string,mixed> $context
|
||||
* @return array{rule:array<string,mixed>,rates:list<array<string,mixed>>}
|
||||
*/
|
||||
private function extractSingleDestinationRates(array $values, array $context): array
|
||||
{
|
||||
$destination = trim((string) ($values[1][1] ?? ''));
|
||||
$destination = trim((string) preg_replace('/^到达省份/u', '', $destination));
|
||||
|
||||
if ($destination === '') {
|
||||
$destination = '江苏';
|
||||
}
|
||||
|
||||
$rates = [];
|
||||
$position = 0;
|
||||
|
||||
foreach ($values as $rowIndex => $row) {
|
||||
if ($rowIndex < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$origin = trim((string) ($row[0] ?? ''));
|
||||
|
||||
if ($origin === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$baseAmount = $this->amount($row[1] ?? null);
|
||||
$incrementAmount = $this->amount($row[2] ?? null);
|
||||
|
||||
if ($baseAmount === null && $incrementAmount === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$position++;
|
||||
$rates[] = $this->rateRow($context, $position, [
|
||||
'origin' => $origin,
|
||||
'destination' => $destination,
|
||||
'weight_band' => null,
|
||||
'base_amount' => $baseAmount,
|
||||
'increment_amount' => $incrementAmount,
|
||||
'source' => $this->cellRef(1, $rowIndex) . ':' . $this->cellRef(2, $rowIndex),
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'rule' => $this->rateRule($context, [
|
||||
'pricing_mode' => 'base_plus_weight_rate',
|
||||
'pricing_label' => '面单 + 重量 × 费率',
|
||||
'base_label' => '面单价格',
|
||||
'increment_label' => '续重价格',
|
||||
'route_label' => '行=出发省份,到达固定为 ' . $destination,
|
||||
'weight_bands' => [],
|
||||
'rates' => $rates,
|
||||
]),
|
||||
'rates' => $rates,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 重量段版式:A 列出发省份、B 列面单费,其后每列一个重量段费率。
|
||||
*
|
||||
* @param list<array<int,mixed>> $values
|
||||
* @param array<string,mixed> $context
|
||||
* @return array{rule:array<string,mixed>,rates:list<array<string,mixed>>}
|
||||
*/
|
||||
private function extractWeightBandRates(array $values, array $context): array
|
||||
{
|
||||
$bands = [];
|
||||
$header = $values[1] ?? [];
|
||||
|
||||
foreach ($header as $index => $label) {
|
||||
if ($index < 3 || ! is_string($label)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = trim($label);
|
||||
|
||||
if ($label === '' || $label === '备注') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bands[$index] = $label;
|
||||
}
|
||||
|
||||
$destination = $this->weightBandDestination($context['notes']);
|
||||
|
||||
$rates = [];
|
||||
$position = 0;
|
||||
|
||||
foreach ($values as $rowIndex => $row) {
|
||||
if ($rowIndex < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$origin = trim((string) ($row[0] ?? ''));
|
||||
|
||||
if ($origin === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$baseAmount = $this->amount($row[1] ?? null);
|
||||
|
||||
foreach ($bands as $index => $band) {
|
||||
$position++;
|
||||
$rates[] = $this->rateRow($context, $position, [
|
||||
'origin' => $origin,
|
||||
'destination' => $destination,
|
||||
'weight_band' => $band,
|
||||
'base_amount' => $baseAmount,
|
||||
'increment_amount' => $this->amount($row[$index] ?? null),
|
||||
'source' => $this->cellRef($index, $rowIndex),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'rule' => $this->rateRule($context, [
|
||||
'pricing_mode' => 'base_plus_weight_rate',
|
||||
'pricing_label' => '面单 + 重量段费率 × 结算重量(不足 15kg 按 15kg 计费,向上取整)',
|
||||
'base_label' => '面单费',
|
||||
'increment_label' => '重量段费率',
|
||||
'route_label' => '行=出发省份,到达固定为 ' . $destination,
|
||||
'weight_bands' => array_values($bands),
|
||||
'rates' => $rates,
|
||||
]),
|
||||
'rates' => $rates,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
* @param array<string,mixed> $values
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function rateRow(array $context, int $position, array $values): array
|
||||
{
|
||||
return [
|
||||
'id' => sprintf('%s-%03d', $context['sheet_name'], $position),
|
||||
'carrier' => $context['carrier'],
|
||||
'sheet_name' => $context['sheet_name'],
|
||||
'quote_type' => $context['quote_type'],
|
||||
'origin' => $values['origin'],
|
||||
'destination' => $values['destination'],
|
||||
'origin_key' => self::provinceKey($values['origin']),
|
||||
'destination_key' => self::provinceKey($values['destination']),
|
||||
'weight_band' => $values['weight_band'],
|
||||
'base_amount' => $values['base_amount'],
|
||||
'increment_amount' => $values['increment_amount'],
|
||||
'available' => $values['base_amount'] !== null || $values['increment_amount'] !== null,
|
||||
'source' => $values['source'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
* @param array<string,mixed> $values
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function rateRule(array $context, array $values): array
|
||||
{
|
||||
$rates = $values['rates'];
|
||||
|
||||
return [
|
||||
'carrier' => $context['carrier'],
|
||||
'sheet_name' => $context['sheet_name'],
|
||||
'quote_type' => $context['quote_type'],
|
||||
'layout' => $context['layout'],
|
||||
'pricing_mode' => $values['pricing_mode'],
|
||||
'pricing_label' => $values['pricing_label'],
|
||||
'base_label' => $values['base_label'],
|
||||
'increment_label' => $values['increment_label'],
|
||||
'route_label' => $values['route_label'],
|
||||
'base_unit' => '元/票',
|
||||
'increment_unit' => '元/kg',
|
||||
'weight_bands' => $values['weight_bands'],
|
||||
'effective_date' => $context['effective_date'],
|
||||
'constraints' => $this->constraintLines($context['notes']),
|
||||
'notes' => $context['notes'],
|
||||
'rate_count' => count($rates),
|
||||
'available_count' => count(array_filter($rates, static fn (array $rate) => $rate['available'])),
|
||||
'origin_count' => count(array_unique(array_column($rates, 'origin'))),
|
||||
'destination_count' => count(array_unique(array_column($rates, 'destination'))),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 备注列:矩阵版式取 A 列尾部说明行,其余版式取最后一列说明。
|
||||
*
|
||||
* @param list<array<int,mixed>> $values
|
||||
* @return list<string>
|
||||
*/
|
||||
private function noteLines(array $values, string $layout): array
|
||||
{
|
||||
[$startRow, $column] = match ($layout) {
|
||||
self::LAYOUT_MATRIX => [3, 0],
|
||||
self::LAYOUT_SINGLE_DESTINATION => [3, 3],
|
||||
self::LAYOUT_WEIGHT_BAND => [2, 5],
|
||||
default => [null, null],
|
||||
};
|
||||
|
||||
if ($column === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
|
||||
foreach ($values as $rowIndex => $row) {
|
||||
if ($rowIndex < $startRow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cell = $row[$column] ?? null;
|
||||
|
||||
if (! is_string($cell) || trim($cell) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (preg_split('/\r\n|\r|\n/u', $cell) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
if ($line !== '') {
|
||||
$lines[] = $line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/** @param list<string> $notes @return list<string> */
|
||||
private function constraintLines(array $notes): array
|
||||
{
|
||||
$keywords = ['限重', '限长', '超标', '超长超重', '保丢', '计泡', '记泡', '抛比'];
|
||||
|
||||
return array_values(array_filter(
|
||||
$notes,
|
||||
static function (string $line) use ($keywords): bool {
|
||||
foreach ($keywords as $keyword) {
|
||||
if (str_contains($line, $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/** @param list<string> $notes */
|
||||
private function weightBandDestination(array $notes): string
|
||||
{
|
||||
foreach ($notes as $line) {
|
||||
if (str_contains($line, '常州仓')) {
|
||||
return '常州仓(江苏)';
|
||||
}
|
||||
}
|
||||
|
||||
return '江苏';
|
||||
}
|
||||
|
||||
private function amount(mixed $value): ?float
|
||||
{
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return round((float) $value, 4);
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '' || in_array($value, ['/', '-', '—', '无'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_numeric($value) ? round((float) $value, 4) : null;
|
||||
}
|
||||
|
||||
private function cellRef(int $columnIndex, int $rowIndex): string
|
||||
{
|
||||
$label = '';
|
||||
$value = $columnIndex + 1;
|
||||
|
||||
while ($value > 0) {
|
||||
$value--;
|
||||
$label = chr(65 + ($value % 26)) . $label;
|
||||
$value = intdiv($value, 26);
|
||||
}
|
||||
|
||||
return $label . ($rowIndex + 1);
|
||||
}
|
||||
|
||||
/** 省份归一口径:去掉“省/市/自治区”后缀,仓名取括号内省份(如“常州仓(江苏)”)。 */
|
||||
public static function provinceKey(string $name): string
|
||||
{
|
||||
$text = trim($name);
|
||||
|
||||
if (preg_match('/^[^((]*[((]([^))]+)[))]$/u', $text, $matches) === 1) {
|
||||
$text = trim($matches[1]);
|
||||
}
|
||||
|
||||
$text = (string) preg_replace('/(维吾尔自治区|壮族自治区|回族自治区|自治区|特别行政区|省|市)$/u', '', $text);
|
||||
|
||||
return $text === '内蒙' ? '内蒙古' : $text;
|
||||
}
|
||||
private function normalizeSheet(array $sheet, int $position): array
|
||||
{
|
||||
$name = trim((string) $sheet['name']);
|
||||
$values = $this->normalizeMatrix($sheet['values'] ?? []);
|
||||
$formulas = $this->normalizeMatrix($sheet['formulas'] ?? []);
|
||||
$columnCount = max(array_map('count', $values ?: [[]]));
|
||||
|
||||
return [
|
||||
'id' => sprintf('RL-%03d', $position + 1),
|
||||
'sheet_index' => (int) ($sheet['index'] ?? $position),
|
||||
'sheet_name' => $name,
|
||||
'company_name' => $this->inferCompanyName($name),
|
||||
'carrier' => $this->inferCarrier($name),
|
||||
'quote_type' => $this->inferQuoteType($name),
|
||||
'effective_date' => $this->extractEffectiveDate($values),
|
||||
'source_range' => trim((string) ($sheet['address'] ?? '')),
|
||||
'row_count' => count($values),
|
||||
'column_count' => $columnCount,
|
||||
'formula_count' => $this->formulaCount($formulas),
|
||||
'status' => $this->extractEffectiveDate($values) === null ? '未标执行日期' : '已标执行日期',
|
||||
'signature_status' => 'Excel 未提供',
|
||||
'values' => $values,
|
||||
'formulas' => $formulas,
|
||||
];
|
||||
}
|
||||
|
||||
private function inferCompanyName(string $name): string
|
||||
{
|
||||
$companyName = trim((string) preg_replace('/(?:逆向|退货|报价表|报价)$/u', '', $name));
|
||||
|
||||
return $companyName !== '' ? $companyName : $name;
|
||||
}
|
||||
|
||||
private function inferCarrier(string $name): string
|
||||
{
|
||||
foreach ([
|
||||
'中通快运', '顺心捷达', '安能快运', '京广', 'EMS', '中通', '圆通', '德邦',
|
||||
'申通', '极兔', '安能', '邮政', '韵达', '顺丰',
|
||||
] as $carrier) {
|
||||
if (str_contains($name, $carrier)) {
|
||||
return $carrier;
|
||||
}
|
||||
}
|
||||
|
||||
return '其他快递';
|
||||
}
|
||||
|
||||
private function inferQuoteType(string $name): string
|
||||
{
|
||||
if (str_contains($name, '退货')) {
|
||||
return '退货报价';
|
||||
}
|
||||
|
||||
if (str_contains($name, '快运')) {
|
||||
return '逆向快运报价';
|
||||
}
|
||||
|
||||
return '逆向报价';
|
||||
}
|
||||
|
||||
private function extractEffectiveDate(array $values): ?string
|
||||
{
|
||||
foreach ($values as $row) {
|
||||
foreach ($row as $cell) {
|
||||
if (! is_string($cell)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/(20\d{2})[.\/\-年]\s*(\d{1,2})[.\/\-月]\s*(\d{1,2})日?/u', $cell, $match) === 1) {
|
||||
return sprintf('%04d-%02d-%02d', (int) $match[1], (int) $match[2], (int) $match[3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeMatrix(mixed $matrix): array
|
||||
{
|
||||
if (! is_array($matrix)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(function ($row) {
|
||||
if (! is_array($row)) {
|
||||
return [$this->normalizeCell($row)];
|
||||
}
|
||||
|
||||
return array_map(fn ($cell) => $this->normalizeCell($cell), array_values($row));
|
||||
}, array_values($matrix));
|
||||
}
|
||||
|
||||
private function normalizeCell(mixed $cell): string|int|float|bool|null
|
||||
{
|
||||
if ($cell === null || is_string($cell) || is_int($cell) || is_float($cell) || is_bool($cell)) {
|
||||
return $cell;
|
||||
}
|
||||
|
||||
return (string) $cell;
|
||||
}
|
||||
|
||||
private function formulaCount(array $formulas): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
foreach ($formulas as $row) {
|
||||
foreach ($row as $formula) {
|
||||
if (is_string($formula) && trim($formula) !== '') {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function emptySnapshot(string $state, string $message): array
|
||||
{
|
||||
return [
|
||||
'available' => false,
|
||||
'state' => $state,
|
||||
'message' => $message,
|
||||
'source_file' => '逆向物流报价.xlsx',
|
||||
'generated_at' => '',
|
||||
'quotes' => [],
|
||||
'rates' => [],
|
||||
'rate_groups' => [],
|
||||
'unparsed_sheets' => [],
|
||||
'provinces' => [
|
||||
'origins' => [],
|
||||
'destinations' => [],
|
||||
],
|
||||
'summary' => [
|
||||
'quote_count' => 0,
|
||||
'carrier_count' => 0,
|
||||
'dated_count' => 0,
|
||||
'formula_count' => 0,
|
||||
],
|
||||
'rate_summary' => [
|
||||
'rate_count' => 0,
|
||||
'carrier_count' => 0,
|
||||
'origin_count' => 0,
|
||||
'destination_count' => 0,
|
||||
'weight_band_count' => 0,
|
||||
'available_count' => 0,
|
||||
'missing_count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
224
app/Support/StreamingXlsxWriter.php
Normal file
224
app/Support/StreamingXlsxWriter.php
Normal file
@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use RuntimeException;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* 低内存流式写出 XLSX 工作簿。
|
||||
*
|
||||
* 退货取件管理等导出允许到数万行,PhpSpreadsheet 的常驻单元格写法在
|
||||
* 512M 内存限制下会失败。这里按最小 OOXML 结构把行顺序写入临时文件,
|
||||
* 最后打包成 zip 输出:内存占用与行数无关。
|
||||
*
|
||||
* 字符串一律写成文本单元格,长订单号/交易单号在 Excel 中不会变成
|
||||
* 科学计数法,也不需要 ' 前缀;int/float 写成数值单元格。
|
||||
*/
|
||||
class StreamingXlsxWriter
|
||||
{
|
||||
private const MAX_ROWS = 1048576;
|
||||
|
||||
/** @var resource */
|
||||
private $sheet;
|
||||
|
||||
private string $sheetPath = '';
|
||||
|
||||
private int $row = 0;
|
||||
|
||||
public function __construct(private readonly string $sheetName = 'Sheet1')
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'xlsx_sheet_');
|
||||
|
||||
if ($path === false) {
|
||||
throw new RuntimeException('无法创建 XLSX 临时文件。');
|
||||
}
|
||||
|
||||
$handle = fopen($path, 'wb');
|
||||
|
||||
if ($handle === false) {
|
||||
@unlink($path);
|
||||
|
||||
throw new RuntimeException('无法写入 XLSX 临时文件。');
|
||||
}
|
||||
|
||||
$this->sheetPath = $path;
|
||||
$this->sheet = $handle;
|
||||
|
||||
fwrite($handle, '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
. '<sheetData>');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string|int|float|null> $cells
|
||||
*/
|
||||
public function appendRow(array $cells): void
|
||||
{
|
||||
if (++$this->row > self::MAX_ROWS) {
|
||||
throw new RuntimeException('导出行数超过 XLSX 单表上限。');
|
||||
}
|
||||
|
||||
$xml = '<row r="' . $this->row . '">';
|
||||
|
||||
foreach (array_values($cells) as $index => $value) {
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reference = $this->columnName($index + 1) . $this->row;
|
||||
$xml .= is_int($value) || is_float($value)
|
||||
? '<c r="' . $reference . '"><v>' . $this->number($value) . '</v></c>'
|
||||
: '<c r="' . $reference . '" t="inlineStr"><is><t xml:space="preserve">'
|
||||
. $this->escape((string) $value) . '</t></is></c>';
|
||||
}
|
||||
|
||||
fwrite($this->sheet, $xml . '</row>');
|
||||
}
|
||||
|
||||
/**
|
||||
* 收尾并写入给定输出流(例如 php://output)。
|
||||
*
|
||||
* @param resource $output
|
||||
*/
|
||||
public function writeTo($output): void
|
||||
{
|
||||
fwrite($this->sheet, '</sheetData></worksheet>');
|
||||
fclose($this->sheet);
|
||||
|
||||
$zipPath = tempnam(sys_get_temp_dir(), 'xlsx_zip_');
|
||||
|
||||
if ($zipPath === false) {
|
||||
$this->cleanup();
|
||||
|
||||
throw new RuntimeException('无法创建 XLSX 打包文件。');
|
||||
}
|
||||
|
||||
try {
|
||||
$zip = new ZipArchive();
|
||||
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
throw new RuntimeException('无法创建 XLSX 压缩包。');
|
||||
}
|
||||
|
||||
$zip->addFromString('[Content_Types].xml', $this->contentTypes());
|
||||
$zip->addFromString('_rels/.rels', $this->rootRelationships());
|
||||
$zip->addFromString('xl/workbook.xml', $this->workbook());
|
||||
$zip->addFromString('xl/_rels/workbook.xml.rels', $this->workbookRelationships());
|
||||
$zip->addFromString('xl/styles.xml', $this->styles());
|
||||
$zip->addFile($this->sheetPath, 'xl/worksheets/sheet1.xml');
|
||||
$zip->close();
|
||||
|
||||
$handle = fopen($zipPath, 'rb');
|
||||
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('无法读取 XLSX 压缩包。');
|
||||
}
|
||||
|
||||
stream_copy_to_stream($handle, $output);
|
||||
fclose($handle);
|
||||
} finally {
|
||||
@unlink($zipPath);
|
||||
$this->cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanup(): void
|
||||
{
|
||||
if ($this->sheetPath !== '' && is_file($this->sheetPath)) {
|
||||
@unlink($this->sheetPath);
|
||||
}
|
||||
}
|
||||
|
||||
private function number(int|float $value): string
|
||||
{
|
||||
if (is_int($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
if (! is_finite($value)) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
$text = rtrim(rtrim(sprintf('%.10F', $value), '0'), '.');
|
||||
|
||||
return ($text === '' || $text === '-') ? '0' : $text;
|
||||
}
|
||||
|
||||
private function escape(string $value): string
|
||||
{
|
||||
if (! mb_check_encoding($value, 'UTF-8')) {
|
||||
$value = mb_convert_encoding($value, 'UTF-8', 'UTF-8');
|
||||
}
|
||||
|
||||
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/u', '', $value) ?? $value;
|
||||
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
}
|
||||
|
||||
private function columnName(int $index): string
|
||||
{
|
||||
$name = '';
|
||||
|
||||
while ($index > 0) {
|
||||
$index--;
|
||||
$name = chr(65 + ($index % 26)) . $name;
|
||||
$index = intdiv($index, 26);
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
private function contentTypes(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
. '<Default Extension="xml" ContentType="application/xml"/>'
|
||||
. '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||||
. '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||||
. '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
|
||||
. '</Types>';
|
||||
}
|
||||
|
||||
private function rootRelationships(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
||||
. '</Relationships>';
|
||||
}
|
||||
|
||||
private function workbook(): string
|
||||
{
|
||||
$name = htmlspecialchars($this->sheetName, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
||||
. 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||
. '<sheets><sheet name="' . $name . '" sheetId="1" r:id="rId1"/></sheets>'
|
||||
. '</workbook>';
|
||||
}
|
||||
|
||||
private function workbookRelationships(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
||||
. '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
|
||||
. '</Relationships>';
|
||||
}
|
||||
|
||||
private function styles(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
. '<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
|
||||
. '<fills count="2"><fill><patternFill patternType="none"/></fill>'
|
||||
. '<fill><patternFill patternType="gray125"/></fill></fills>'
|
||||
. '<borders count="1"><border/></borders>'
|
||||
. '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
|
||||
. '<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>'
|
||||
. '</styleSheet>';
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,11 @@ class SidebarPermissions
|
||||
'refunds/small-payment-work-orders' => 'financial-operations/small-payment-work-orders',
|
||||
];
|
||||
|
||||
private const FINAL_SECTION_KEYS = [
|
||||
'supplier-payables' => 'supplier-payables-final',
|
||||
'courier-payables' => 'courier-payables-final',
|
||||
];
|
||||
|
||||
public static function settings(): array
|
||||
{
|
||||
$value = setting('system.sidebar_permissions', []);
|
||||
@ -71,7 +76,7 @@ class SidebarPermissions
|
||||
return $navigation;
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(function (array $section) use ($allowed) {
|
||||
$navigation = array_values(array_filter(array_map(function (array $section) use ($allowed) {
|
||||
$sectionKey = $section['slug'];
|
||||
|
||||
if (in_array($sectionKey, $allowed, true)) {
|
||||
@ -113,6 +118,38 @@ class SidebarPermissions
|
||||
|
||||
return null;
|
||||
}, $navigation)));
|
||||
|
||||
// 新增的公司成本安排、税金预测表与主体经营报表共用公司经营报表权限。
|
||||
// 对已经保存过精确权限的账号兼容旧配置,避免新增页面被旧权限集合隐藏。
|
||||
foreach ($navigation as &$section) {
|
||||
if (($section['slug'] ?? null) !== 'company-management') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasSubjectReport = collect($section['pages'] ?? [])->contains('slug', 'subject-operating-report');
|
||||
|
||||
if ($hasSubjectReport) {
|
||||
$configuredPages = collect(BusinessNavigation::all())
|
||||
->firstWhere('slug', 'company-management')['pages'] ?? [];
|
||||
|
||||
foreach (['company-cost-arrangement', 'tax-forecast'] as $sharedReportSlug) {
|
||||
$alreadyVisible = collect($section['pages'] ?? [])->contains('slug', $sharedReportSlug);
|
||||
|
||||
if ($alreadyVisible) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sharedReportPage = collect($configuredPages)->firstWhere('slug', $sharedReportSlug);
|
||||
|
||||
if ($sharedReportPage) {
|
||||
$section['pages'][] = $sharedReportPage;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($section);
|
||||
|
||||
return $navigation;
|
||||
}
|
||||
|
||||
public static function allKeys(): array
|
||||
@ -140,7 +177,17 @@ class SidebarPermissions
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$key = (string) $key;
|
||||
$normalized[] = self::LEGACY_PAGE_KEYS[$key] ?? $key;
|
||||
$normalizedKey = self::LEGACY_PAGE_KEYS[$key] ?? $key;
|
||||
$normalized[] = $normalizedKey;
|
||||
|
||||
foreach (self::FINAL_SECTION_KEYS as $sectionKey => $finalSectionKey) {
|
||||
if ($normalizedKey === $sectionKey) {
|
||||
$normalized[] = $finalSectionKey;
|
||||
} elseif (str_starts_with($normalizedKey, $sectionKey . '/')) {
|
||||
// 「终版」目录已是独立目录,按同样的子页面粒度跟着授权。
|
||||
$normalized[] = $finalSectionKey . substr($normalizedKey, strlen($sectionKey));
|
||||
}
|
||||
}
|
||||
|
||||
if ($key === 'refunds') {
|
||||
$normalized[] = self::LEGACY_PAGE_KEYS['refunds/small-payment-work-orders'];
|
||||
|
||||
@ -60,6 +60,7 @@ class Title extends Component
|
||||
$context = Str::lower($routeName . ' ' . request()->path());
|
||||
|
||||
$icons = [
|
||||
'financial-risk-control' => 'security',
|
||||
'store-receiving-bills' => 'account_balance_wallet',
|
||||
'marketing-account-bills' => 'account_balance_wallet',
|
||||
'supplier-payables' => 'local_shipping',
|
||||
|
||||
@ -27,11 +27,16 @@ services:
|
||||
env_file:
|
||||
- ./runtime/config-db.env
|
||||
command:
|
||||
- "--innodb-buffer-pool-size=512M"
|
||||
- "--innodb-log-buffer-size=32M"
|
||||
- "--innodb-buffer-pool-size=4G"
|
||||
- "--innodb-log-buffer-size=64M"
|
||||
- "--max-connections=60"
|
||||
- "--table-open-cache=600"
|
||||
- "--performance-schema=OFF"
|
||||
- "--slow-query-log=ON"
|
||||
- "--long-query-time=5"
|
||||
- "--min-examined-row-limit=10000"
|
||||
- "--log-output=FILE"
|
||||
- "--slow-query-log-file=/var/lib/mysql/caiwu-slow.log"
|
||||
ports:
|
||||
- "0.0.0.0:13306:3306"
|
||||
volumes:
|
||||
@ -40,8 +45,8 @@ services:
|
||||
networks:
|
||||
- default
|
||||
- config_database
|
||||
mem_limit: 1536m
|
||||
cpus: "2.00"
|
||||
mem_limit: 6g
|
||||
cpus: "4.00"
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
@ -95,6 +100,8 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-0}
|
||||
SUPPLIER_PAYABLE_MAINTENANCE_DB_CONNECTION: business_mirror
|
||||
JST_DB_CONNECTION: business_mirror
|
||||
env_file:
|
||||
- ./runtime/qiniu-storage.env
|
||||
- ./runtime/clickhouse.env
|
||||
|
||||
@ -92,6 +92,18 @@ return [
|
||||
|
||||
'timezone' => env('APP_TIMEZONE', 'UTC'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 平台固定时区
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| app.timezone 会被 App\Utilities\Overrider 按公司设置 localisation.timezone 覆盖,
|
||||
| 没有该设置时回落到 Europe/London,导致页面请求写入的时间比北京时间晚 7 小时。
|
||||
| 需要稳定时间的地方(如科目映射表的操作记录)用这个值,不随公司设置变化。
|
||||
|
|
||||
*/
|
||||
'platform_timezone' => env('APP_TIMEZONE', 'UTC'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|
||||
@ -47,6 +47,7 @@ return [
|
||||
'operations_replica.rpt.fys_companyInfo' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '公司资料'],
|
||||
'operations_replica.rpt.fys_shopInfo' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '店铺资料/店铺报表'],
|
||||
'operations_replica.rpt.fys_TrademarkInfo' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '商标资料'],
|
||||
'operations_replica.rpt.crawl_shop_company' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '店铺基础信息/店铺主体期间'],
|
||||
'operations_replica.rpt.jst_sku' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '商品资料/售后费用'],
|
||||
'jiansun.dbo.pm_priceAdjustment' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '普通商品资料/工作平台价格'],
|
||||
'operations_replica.rpt.jst_warehouseInfo' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '售后费用'],
|
||||
@ -97,6 +98,9 @@ return [
|
||||
'jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving' => [
|
||||
'role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '店铺收款账单/拼多多账户',
|
||||
],
|
||||
'jiansun.dbo.jst_originalBillInfo.dewu_receiving' => [
|
||||
'role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '店铺收款账单/得物账户',
|
||||
],
|
||||
'jiansun.dbo.alipay_Bill_Detail' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '店铺收款账单/淘系支付宝账户'],
|
||||
'jiansun.dbo.tmall_spread_bills' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '淘宝推广账单'],
|
||||
'jiansun.dbo.pdd_spread_bills' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '拼多多推广账单'],
|
||||
|
||||
@ -26,6 +26,62 @@ $navigation = [
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'company-cost-arrangement',
|
||||
'title' => '公司成本安排',
|
||||
'directory' => [
|
||||
'icon' => 'account_balance_wallet',
|
||||
'status' => '报表待定义',
|
||||
'description' => '按公司主体查看当月收入、成本缺口及成本费用安排。',
|
||||
'empty' => '公司成本安排尚未同步数据。',
|
||||
'fields' => [
|
||||
['name' => 'company', 'label' => '公司'],
|
||||
['name' => 'company_type', 'label' => '公司类型'],
|
||||
['name' => 'invoice_type', 'label' => '发票类型'],
|
||||
['name' => 'current_month_income_forecast', 'label' => '当月收入预测', 'kind' => 'number'],
|
||||
['name' => 'historical_cost_gap', 'label' => '往期成本缺口', 'kind' => 'number'],
|
||||
['name' => 'supplier_cost_arrangement', 'label' => '供应商成本安排', 'kind' => 'number'],
|
||||
['name' => 'courier_cost_arrangement', 'label' => '快递成本安排', 'kind' => 'number'],
|
||||
['name' => 'current_month_tax_forecast', 'label' => '当月税金预测', 'kind' => 'number'],
|
||||
['name' => 'labor_cost_forecast', 'label' => '人工成本预测', 'kind' => 'number'],
|
||||
['name' => 'other_expense_forecast', 'label' => '其他费用预测', 'kind' => 'number'],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'tax-forecast',
|
||||
'title' => '税金预测表',
|
||||
'directory' => [
|
||||
'icon' => 'calculate',
|
||||
'status' => '字段已定义',
|
||||
'index_label' => '序号',
|
||||
'description' => '按公司主体预测当月税金:先按公司税务类型、工商类型和征收方式确定口径,再计算增值税及附加、印花税和所得税;取数公式见 docs/税金预测表需求说明.md。',
|
||||
'empty' => '税金预测表尚未接入取数:工商类型、征收方式(查账征收/核定征收)确认并补齐数据来源后开始计算。',
|
||||
'fields' => [
|
||||
['name' => 'company_name', 'label' => '公司名称'],
|
||||
['name' => 'taxpayer_type', 'label' => '公司税务类型'],
|
||||
['name' => 'business_registration_type', 'label' => '工商类型'],
|
||||
['name' => 'collection_method', 'label' => '征收方式'],
|
||||
['name' => 'assessed_income_rate', 'label' => '核定应税所得率(%)', 'kind' => 'number'],
|
||||
['name' => 'forecast_month', 'label' => '预测月份'],
|
||||
['name' => 'revenue_incl_tax', 'label' => '当月含税收入预测', 'kind' => 'number'],
|
||||
['name' => 'revenue_excl_tax', 'label' => '当月不含税收入', 'kind' => 'number'],
|
||||
['name' => 'quarter_sales_excl_tax', 'label' => '季度累计销售额(不含税)', 'kind' => 'number'],
|
||||
['name' => 'cost_forecast', 'label' => '当月成本费用预测', 'kind' => 'number'],
|
||||
['name' => 'taxable_income', 'label' => '预计应纳税所得额', 'kind' => 'number'],
|
||||
['name' => 'vat_rate', 'label' => '增值税征收率/税率'],
|
||||
['name' => 'vat_payable', 'label' => '预计增值税', 'kind' => 'number'],
|
||||
['name' => 'urban_maintenance_tax', 'label' => '城建税', 'kind' => 'number'],
|
||||
['name' => 'education_surcharge', 'label' => '教育费附加', 'kind' => 'number'],
|
||||
['name' => 'local_education_surcharge', 'label' => '地方教育附加', 'kind' => 'number'],
|
||||
['name' => 'stamp_duty', 'label' => '印花税', 'kind' => 'number'],
|
||||
['name' => 'income_tax', 'label' => '企业所得税/经营所得个税', 'kind' => 'number'],
|
||||
['name' => 'total_tax', 'label' => '税金合计', 'kind' => 'number'],
|
||||
['name' => 'tax_burden_rate', 'label' => '综合税负率(%)', 'kind' => 'number'],
|
||||
['name' => 'notes', 'label' => '备注'],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'basic-information',
|
||||
'title' => '公司基础信息',
|
||||
@ -173,6 +229,14 @@ $navigation = [
|
||||
'fields' => [],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'store-report-data-logic',
|
||||
'title' => '店铺维度报表逻辑配置',
|
||||
],
|
||||
[
|
||||
'slug' => 'tax-forecast-data-logic',
|
||||
'title' => '税金预测表逻辑配置',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
@ -213,6 +277,33 @@ $navigation = [
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'internal-flow-report',
|
||||
'title' => '内部流转报表',
|
||||
'directory' => [
|
||||
'icon' => 'swap_horiz',
|
||||
'status' => '共享镜像',
|
||||
'description' => '按《一般纳税人内部流转》工作簿导入:一行一个抬头,展示预计销量、开票、所需进项成本、收票来源与剩余缺口。',
|
||||
'empty' => '内部流转数据尚未导入。导入工作簿后这里会显示明细。',
|
||||
'fields' => [
|
||||
['name' => 'company_name', 'label' => '抬头'],
|
||||
['name' => 'tag', 'label' => '标签'],
|
||||
['name' => 'projected_sales', 'label' => '预计9月销量', 'kind' => 'number'],
|
||||
['name' => 'invoiced_to_buyer', 'label' => '开票-给买家开票', 'kind' => 'number'],
|
||||
['name' => 'internal_invoiced', 'label' => '开票-内部开票', 'kind' => 'number'],
|
||||
['name' => 'required_input_cost', 'label' => '所需进项成本', 'kind' => 'number'],
|
||||
['name' => 'receipt_from_supplier', 'label' => '收票-供应商', 'kind' => 'number'],
|
||||
['name' => 'receipt_from_suofeiya', 'label' => '收票-索菲亚', 'kind' => 'number'],
|
||||
['name' => 'receipt_from_gujia', 'label' => '收票-顾家', 'kind' => 'number'],
|
||||
['name' => 'internal_transfer_1', 'label' => '收票-内部流转(来源)1', 'kind' => 'number'],
|
||||
['name' => 'internal_transfer_2', 'label' => '收票-内部流转(来源)2', 'kind' => 'number'],
|
||||
['name' => 'internal_transfer_3', 'label' => '收票-内部流转(来源)3', 'kind' => 'number'],
|
||||
['name' => 'receipt_total', 'label' => '收票-合计', 'kind' => 'number'],
|
||||
['name' => 'remaining_gap', 'label' => '剩余缺口', 'kind' => 'number'],
|
||||
['name' => 'invoicing_ratio', 'label' => '开票比例'],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'basic-information',
|
||||
'title' => '店铺基础信息',
|
||||
@ -302,6 +393,68 @@ $navigation = [
|
||||
'pages' => [
|
||||
['slug' => 'normal-products', 'title' => '普通商品资料'],
|
||||
['slug' => 'online-daifa-price', 'title' => '线上代发价格'],
|
||||
[
|
||||
'slug' => 'monthly-weighted-average-price',
|
||||
'title' => '商品月加权平均单价',
|
||||
'directory' => [
|
||||
'icon' => 'calculate',
|
||||
'status' => '共享镜像',
|
||||
'description' => '商品月加权平均单价页面入口已建立;接入数据前需确认采购入库范围、金额字段和退货处理口径。',
|
||||
'empty' => '商品月加权平均单价尚未同步。确认取数规则后再接入经营分析副本库数据。',
|
||||
'fields' => [
|
||||
['name' => 'month', 'label' => '月份', 'kind' => 'date'],
|
||||
['name' => 'barcode_warehouse', 'label' => '条码+仓库'],
|
||||
['name' => 'warehouse_short_name', 'label' => '仓库简称'],
|
||||
['name' => 'supplier', 'label' => '供应商'],
|
||||
['name' => 'product_code', 'label' => '商品编码'],
|
||||
['name' => 'product_name', 'label' => '商品名称'],
|
||||
['name' => 'opening_quantity', 'label' => '原始数量', 'kind' => 'number', 'group' => '期初数据'],
|
||||
['name' => 'opening_unit_price', 'label' => '原始价格', 'kind' => 'number', 'group' => '期初数据'],
|
||||
['name' => 'opening_original_amount', 'label' => '原始期初金额', 'kind' => 'number', 'group' => '期初数据'],
|
||||
['name' => 'opening_adjusted_price', 'label' => '更改价格', 'kind' => 'number', 'group' => '期初数据'],
|
||||
['name' => 'opening_amount', 'label' => '期初金额', 'kind' => 'number', 'group' => '期初数据'],
|
||||
['name' => 'purchase_quantity', 'label' => '采购数量', 'kind' => 'number', 'group' => '入库数据'],
|
||||
['name' => 'purchase_unit_price', 'label' => '采购单价', 'kind' => 'number', 'group' => '入库数据'],
|
||||
['name' => 'purchase_amount', 'label' => '入库金额', 'kind' => 'number', 'group' => '入库数据'],
|
||||
['name' => 'weighted_average_unit_price', 'label' => '加权平均单价', 'kind' => 'number'],
|
||||
['name' => 'pricing_status', 'label' => '定价状态'],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'inventory-report',
|
||||
'title' => '进销存报表',
|
||||
'directory' => [
|
||||
'icon' => 'inventory',
|
||||
'status' => '共享镜像',
|
||||
'description' => '复用“应付款项 → 快递应付 → 三方仓进销存”的已确认流水、库存锚点和业务类型口径,按所有仓库、商品和月份汇总。',
|
||||
'empty' => '进销存共享镜像尚未完成首次同步。',
|
||||
'fields' => [
|
||||
['name' => 'owner', 'label' => '货主'],
|
||||
['name' => 'month', 'label' => '月份', 'kind' => 'date'],
|
||||
['name' => 'warehouse_provider', 'label' => '仓储方'],
|
||||
['name' => 'warehouse', 'label' => '仓库'],
|
||||
['name' => 'warehouse_short_name', 'label' => '仓库简称'],
|
||||
['name' => 'product_code', 'label' => '商品编码'],
|
||||
['name' => 'category', 'label' => '分类'],
|
||||
['name' => 'opening_quantity', 'label' => '期初数量', 'kind' => 'integer'],
|
||||
['name' => 'closing_quantity', 'label' => '期末数量', 'kind' => 'integer'],
|
||||
['name' => 'processing_in', 'label' => '加工进仓', 'kind' => 'integer'],
|
||||
['name' => 'processing_out', 'label' => '加工出仓', 'kind' => 'integer'],
|
||||
['name' => 'purchase_in', 'label' => '采购进仓', 'kind' => 'integer'],
|
||||
['name' => 'purchase_return', 'label' => '采购退货', 'kind' => 'integer'],
|
||||
['name' => 'other_in', 'label' => '其它进仓', 'kind' => 'integer'],
|
||||
['name' => 'other_return', 'label' => '其它退货', 'kind' => 'integer'],
|
||||
['name' => 'sales_out', 'label' => '销售出仓', 'kind' => 'integer'],
|
||||
['name' => 'sales_return', 'label' => '销售退货', 'kind' => 'integer'],
|
||||
['name' => 'stocktake', 'label' => '盘点', 'kind' => 'integer'],
|
||||
['name' => 'transfer_out', 'label' => '调拨出', 'kind' => 'integer'],
|
||||
['name' => 'transfer_in', 'label' => '调拨入', 'kind' => 'integer'],
|
||||
['name' => 'opening', 'label' => '期初', 'kind' => 'integer'],
|
||||
['name' => 'other_out', 'label' => '其它出库', 'kind' => 'integer'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -339,9 +492,12 @@ $navigation = [
|
||||
['slug' => 'taobao-wechat', 'title' => '淘系微信账户'],
|
||||
['slug' => 'pinduoduo', 'title' => '拼多多账户'],
|
||||
['slug' => 'jd-wallet', 'title' => '京东网银钱包账户'],
|
||||
['slug' => 'jd-transactions', 'title' => '京东流水'],
|
||||
['slug' => 'jd-transactions', 'title' => '京东账单'],
|
||||
['slug' => 'jingxi', 'title' => '京喜账单'],
|
||||
['slug' => 'douyin', 'title' => '抖音账户'],
|
||||
['slug' => 'dewu', 'title' => '得物账户'],
|
||||
['slug' => 'vipshop', 'title' => '唯品会MP账户'],
|
||||
['slug' => 'vipshop-jx', 'title' => '唯品会JX账户'],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -368,11 +524,20 @@ $navigation = [
|
||||
['slug' => 'jd-wallet', 'title' => '京东网银钱包账户'],
|
||||
['slug' => 'douyin', 'title' => '抖音账户'],
|
||||
['slug' => 'dewu', 'title' => '得物账户'],
|
||||
['slug' => 'vipshop', 'title' => '唯品会账户'],
|
||||
['slug' => 'vipshop', 'title' => '唯品会MP账户'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'planting-data',
|
||||
'title' => '种菜数据汇总',
|
||||
'icon' => 'grass',
|
||||
'pages' => [
|
||||
['slug' => 'planting-details', 'title' => '种菜明细表'],
|
||||
['slug' => 'jst-order-labels', 'title' => '聚水潭订单标签明细'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'account-balance-data',
|
||||
'title' => '账单余额数据',
|
||||
@ -390,7 +555,7 @@ $navigation = [
|
||||
['slug' => 'jd-wallet', 'title' => '京东账户'],
|
||||
['slug' => 'douyin', 'title' => '抖音账户'],
|
||||
['slug' => 'dewu', 'title' => '得物账户'],
|
||||
['slug' => 'vipshop', 'title' => '唯品会账户'],
|
||||
['slug' => 'vipshop', 'title' => '唯品会MP账户'],
|
||||
],
|
||||
],
|
||||
['slug' => 'deposit-account-balance', 'title' => '店铺保证金账户余额'],
|
||||
@ -455,13 +620,50 @@ $navigation = [
|
||||
'slug' => 'supplier-payables-final',
|
||||
'title' => '供应商应付(终版)',
|
||||
'icon' => 'local_shipping',
|
||||
'pages' => [],
|
||||
'pages' => [
|
||||
['slug' => 'product-prices', 'title' => '商品价格'],
|
||||
['slug' => 'payment-reconciliation', 'title' => '货款对账'],
|
||||
['slug' => 'return-reconciliation', 'title' => '退货对账'],
|
||||
['slug' => 'invoice-deductions', 'title' => '发票扣款'],
|
||||
['slug' => 'board-deductions', 'title' => '板材扣款'],
|
||||
['slug' => 'compensation-details', 'title' => '赔偿明细'],
|
||||
['slug' => 'advance-details', 'title' => '借支明细'],
|
||||
['slug' => 'sample-deductions', 'title' => '样品扣款'],
|
||||
['slug' => 'other-adjustments', 'title' => '其他扣款补款'],
|
||||
[
|
||||
'slug' => 'unpriced-details',
|
||||
'title' => '未定价明细',
|
||||
'children' => [
|
||||
['slug' => 'delivery-unpriced', 'title' => '送货未定价'],
|
||||
['slug' => 'return-unpriced', 'title' => '退货未定价'],
|
||||
],
|
||||
],
|
||||
['slug' => 'dropshipping-express', 'title' => '代发快递'],
|
||||
['slug' => 'supplier-payables-report', 'title' => '供应商应付表'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'courier-payables-final',
|
||||
'title' => '快递应付(终版)',
|
||||
'icon' => 'local_shipping',
|
||||
'pages' => [],
|
||||
'pages' => [
|
||||
['slug' => 'quote-management', 'title' => '报价管理'],
|
||||
['slug' => 'courier-information', 'title' => '快递资料'],
|
||||
['slug' => 'agreement-signing', 'title' => '协议签署'],
|
||||
['slug' => 'freight-reconciliation', 'title' => '运费对账'],
|
||||
['slug' => 'shipping-data', 'title' => '发货数据'],
|
||||
['slug' => 'third-party-warehouse-storage', 'title' => '三方仓进销存'],
|
||||
['slug' => 'bookkeeping-details', 'title' => '记账明细'],
|
||||
['slug' => 'compensation-details', 'title' => '赔偿明细'],
|
||||
['slug' => 'advance-details', 'title' => '借支明细'],
|
||||
['slug' => 'other-adjustments', 'title' => '其他扣款补款'],
|
||||
['slug' => 'courier-surcharge', 'title' => '附加费报价维护'],
|
||||
['slug' => 'payable-cloud-warehouse-vehicle-fee', 'title' => '应付云仓车费'],
|
||||
['slug' => 'courier-payables-report', 'title' => '快递应付表'],
|
||||
['slug' => 'freight-difference', 'title' => '运费差'],
|
||||
['slug' => 'other-surcharge-summary', 'title' => '其他附加费汇总'],
|
||||
['slug' => 'return-fee', 'title' => '退件费'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'supplier-payables',
|
||||
@ -499,6 +701,7 @@ $navigation = [
|
||||
['slug' => 'agreement-signing', 'title' => '协议签署'],
|
||||
['slug' => 'freight-reconciliation', 'title' => '运费对账'],
|
||||
['slug' => 'shipping-data', 'title' => '发货数据'],
|
||||
['slug' => 'return-pickup-management', 'title' => '退货取件管理'],
|
||||
['slug' => 'third-party-warehouse-storage', 'title' => '三方仓进销存'],
|
||||
['slug' => 'bookkeeping-details', 'title' => '记账明细'],
|
||||
['slug' => 'compensation-details', 'title' => '赔偿明细'],
|
||||
@ -507,6 +710,9 @@ $navigation = [
|
||||
['slug' => 'courier-surcharge', 'title' => '附加费报价维护'],
|
||||
['slug' => 'payable-cloud-warehouse-vehicle-fee', 'title' => '应付云仓车费'],
|
||||
['slug' => 'courier-payables-report', 'title' => '快递应付表'],
|
||||
['slug' => 'freight-difference', 'title' => '运费差'],
|
||||
['slug' => 'other-surcharge-summary', 'title' => '其他附加费汇总'],
|
||||
['slug' => 'return-fee', 'title' => '退件费'],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -575,7 +781,27 @@ $navigation = [
|
||||
'title' => '财务经营报表',
|
||||
'icon' => 'analytics',
|
||||
'pages' => [
|
||||
['slug' => 'performance-details', 'title' => '业绩明细'],
|
||||
[
|
||||
'slug' => 'performance-details',
|
||||
'title' => '业绩明细',
|
||||
'directory' => [
|
||||
'icon' => 'receipt_long',
|
||||
'status' => '共享镜像',
|
||||
'description' => '以店铺收款账户账单为基数,按「店铺 + 订单号」匹配订单侧商品金额;一行 = 一个店铺的一个订单,商品金额只挂一次,差额保留供后续分类。',
|
||||
'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。',
|
||||
'fields' => [
|
||||
['name' => 'occurred_date', 'label' => '发生时间', 'kind' => 'date'],
|
||||
['name' => 'store_name', 'label' => '店铺名称'],
|
||||
['name' => 'online_order_no', 'label' => '线上单号'],
|
||||
['name' => 'income_amount', 'label' => '收入金额', 'kind' => 'number'],
|
||||
['name' => 'product_amount', 'label' => '商品金额', 'kind' => 'number'],
|
||||
['name' => 'difference', 'label' => '差额', 'kind' => 'number'],
|
||||
['name' => 'match_status', 'label' => '匹配状态'],
|
||||
['name' => 'remark', 'label' => '异常标注'],
|
||||
],
|
||||
],
|
||||
],
|
||||
['slug' => 'billing-details', 'title' => '账单明细'],
|
||||
['slug' => 'product-costs', 'title' => '商品成本'],
|
||||
['slug' => 'courier-freight', 'title' => '快递运费'],
|
||||
['slug' => 'installation-details', 'title' => '安装明细'],
|
||||
@ -591,6 +817,14 @@ $navigation = [
|
||||
['slug' => 'financial-analysis', 'title' => '财务经营分析表'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'financial-risk-control',
|
||||
'title' => '财务经营风险管控',
|
||||
'icon' => 'security',
|
||||
'pages' => [
|
||||
['slug' => 'store-violation-alert', 'title' => '店铺违规信息预警'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'financial-sw',
|
||||
'title' => '财务SW报表',
|
||||
@ -600,6 +834,17 @@ $navigation = [
|
||||
['slug' => 'actual-statistics', 'title' => '实际SW统计表'],
|
||||
],
|
||||
],
|
||||
// 注意:新分组要放在 financial-sw 之后。
|
||||
// NavigationTest 断言 financial-risk-control 的下一个分组必须是 financial-sw,
|
||||
// 插在它俩之间会让既有测试失败(等于改动了别人的页面顺序)。
|
||||
[
|
||||
'slug' => 'financial-rules',
|
||||
'title' => '财务规则',
|
||||
'icon' => 'rule',
|
||||
'pages' => [
|
||||
['slug' => 'account-mapping', 'title' => '科目映射表'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'data-monitoring',
|
||||
'title' => '数据监控',
|
||||
@ -680,6 +925,7 @@ $requestedSectionOrder = [
|
||||
'order-data',
|
||||
'refunds',
|
||||
'billing-transactions',
|
||||
'planting-data',
|
||||
'account-balance-data',
|
||||
'product-inventory',
|
||||
];
|
||||
|
||||
@ -45,6 +45,13 @@ return [
|
||||
|
||||
'default' => env('DB_CONNECTION', 'mysql'),
|
||||
|
||||
// Supplier payable edits and imports must never write to the shared
|
||||
// system database used by the rest of the application.
|
||||
'supplier_payable_maintenance_connection' => env(
|
||||
'SUPPLIER_PAYABLE_MAINTENANCE_DB_CONNECTION',
|
||||
'supplier_payable_maintenance'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
@ -169,6 +176,30 @@ return [
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => 'InnoDB ROW_FORMAT=DYNAMIC',
|
||||
|
||||
],
|
||||
|
||||
'supplier_payable_maintenance' => [
|
||||
'driver' => env('SUPPLIER_PAYABLE_MAINTENANCE_DB_DRIVER', 'mysql'),
|
||||
'host' => env('SUPPLIER_PAYABLE_MAINTENANCE_DB_HOST'),
|
||||
'port' => env('SUPPLIER_PAYABLE_MAINTENANCE_DB_PORT', '3306'),
|
||||
'database' => env('SUPPLIER_PAYABLE_MAINTENANCE_DB_DATABASE'),
|
||||
'username' => env('SUPPLIER_PAYABLE_MAINTENANCE_DB_USERNAME'),
|
||||
'password' => env('SUPPLIER_PAYABLE_MAINTENANCE_DB_PASSWORD'),
|
||||
'unix_socket' => env('SUPPLIER_PAYABLE_MAINTENANCE_DB_SOCKET', ''),
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'prefix' => env('SUPPLIER_PAYABLE_MAINTENANCE_DB_PREFIX', 'ak_'),
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => 'InnoDB ROW_FORMAT=DYNAMIC',
|
||||
'modes' => [
|
||||
'STRICT_TRANS_TABLES',
|
||||
'NO_ZERO_IN_DATE',
|
||||
'NO_ZERO_DATE',
|
||||
'ERROR_FOR_DIVISION_BY_ZERO',
|
||||
'NO_ENGINE_SUBSTITUTION',
|
||||
],
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
|
||||
@ -418,7 +418,10 @@ return [
|
||||
|
||||
'inputs' => [
|
||||
'only' => [], // i.e. 'first_name'
|
||||
'except' => [], // i.e. 'password'
|
||||
// Livewire cryptographically signs this serialized component
|
||||
// state. Its large JSON payload produces false positives; user
|
||||
// updates and every other request field remain protected.
|
||||
'except' => ['snapshot'],
|
||||
],
|
||||
|
||||
'patterns' => [
|
||||
|
||||
24
config/jd_bill_platform.php
Normal file
24
config/jd_bill_platform.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 京东账单平台口径
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 京东日账单导出没有平台字段(单据类型两边都有,费用项/账户代码/门店编码等为空),
|
||||
| 只能按店铺名区分京喜与京东:「京东账单」排除京喜店铺,「京喜账单」只取京喜店铺。
|
||||
| 两个口径互补,笔数与金额之和恒等于拆分前,不修改任何底层数据。
|
||||
| 口径的读取与收窄逻辑见 Modules\EcommerceFinance\JdBillPlatform 与 JdDailyBillRepository。
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
// 京喜店铺在日账单「店铺」列里的固定关键字(京喜自营工厂店、京喜小店等均含此词)。
|
||||
'jingxi_store_name_keyword' => '京喜',
|
||||
|
||||
// 口径切换起始日期(按记账日期/费用结算时间,格式 Y-m-d)。null = 全量历史。
|
||||
// 改成 '2026-09-01' 后:该日期起的京喜行归入京喜账单,之前的京喜行仍留在京东账单,总数保持守恒。
|
||||
'scope_start_date' => null,
|
||||
|
||||
];
|
||||
@ -59,16 +59,18 @@ return [
|
||||
|
||||
'jushuitan' => [
|
||||
// 聚水潭开放平台凭据只从私有 runtime/.env 读取,不进入代码仓库。
|
||||
'base_url' => env('JUSHUITAN_OPEN_API_URL', 'https://openapi.jushuitan.com'),
|
||||
'app_key' => env('JUSHUITAN_APP_KEY'),
|
||||
'app_secret' => env('JUSHUITAN_APP_SECRET'),
|
||||
'access_token' => env('JUSHUITAN_ACCESS_TOKEN'),
|
||||
// 商品操作日志使用经典开放接口。经典接口的 partnerid / partnerkey / token
|
||||
// 与新开放平台的 App Key / App Secret / Access Token 不是同一套凭据,禁止混用。
|
||||
'base_url' => env('JUSHUITAN_CLASSIC_API_URL', 'https://open.erp321.com/api/open/query.aspx'),
|
||||
'partner_id' => env('JUSHUITAN_PARTNER_ID'),
|
||||
'partner_key' => env('JUSHUITAN_PARTNER_KEY'),
|
||||
'token' => env('JUSHUITAN_TOKEN'),
|
||||
'timeout' => (int) env('JUSHUITAN_OPEN_API_TIMEOUT', 20),
|
||||
'product_operation_history_path' => '/open/webapi/itemapi/itemsku/queryitemskuopshistory',
|
||||
'product_operation_history_method' => 'worklog.query',
|
||||
'cost_price_history_start' => env('JUSHUITAN_COST_PRICE_HISTORY_START', '2000-01-01 00:00:00'),
|
||||
'cost_price_history_window_days' => (int) env('JUSHUITAN_COST_PRICE_HISTORY_WINDOW_DAYS', 30),
|
||||
'cost_price_history_window_days' => (int) env('JUSHUITAN_COST_PRICE_HISTORY_WINDOW_DAYS', 7),
|
||||
'cost_price_history_overlap_hours' => (int) env('JUSHUITAN_COST_PRICE_HISTORY_OVERLAP_HOURS', 48),
|
||||
'cost_price_history_page_size' => (int) env('JUSHUITAN_COST_PRICE_HISTORY_PAGE_SIZE', 2000),
|
||||
'cost_price_history_page_size' => (int) env('JUSHUITAN_COST_PRICE_HISTORY_PAGE_SIZE', 50),
|
||||
'cost_price_history_max_pages' => (int) env('JUSHUITAN_COST_PRICE_HISTORY_MAX_PAGES', 30),
|
||||
],
|
||||
|
||||
|
||||
587
config/store_report_initial_periods.php
Normal file
587
config/store_report_initial_periods.php
Normal file
@ -0,0 +1,587 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 店铺维度报表的期初主体数据(2026-07-01 起)。
|
||||
*
|
||||
* 数据来自负责人提供的《店铺变更初始数据xlsx.xlsx》:每个变更店铺两行,
|
||||
* 老主体自 2026-07-01 起生效、失效时间为店铺变更到新主体的当天;
|
||||
* 报表按前一天展示失效时间,避免与到新主体的生效时间重叠,
|
||||
* 因此这里直接保存「变更日前一天」作为老主体期间的失效时间。
|
||||
*
|
||||
* 未列入本表的店铺统一按 2026-07-01 起的当前主体计算,不再使用流程历史。
|
||||
*/
|
||||
return [
|
||||
'initial_effective_at' => '2026-07-01',
|
||||
'shops' => [
|
||||
'京东-万客达家具旗舰店' => [
|
||||
'previous_company' => '常州微振家居有限公司',
|
||||
'company' => '武汉市朵莹米贸易有限公司',
|
||||
'changed_at' => '2026-07-27',
|
||||
],
|
||||
'京东-亿家木家具旗舰店' => [
|
||||
'previous_company' => '常州玺之橙家居有限公司',
|
||||
'company' => '株洲市森境商贸有限公司',
|
||||
'changed_at' => '2026-09-14',
|
||||
],
|
||||
'京东-亿家达家居旗舰店' => [
|
||||
'previous_company' => '常州州圣家居有限公司',
|
||||
'company' => '广州芯喻科技有限公司',
|
||||
'changed_at' => '2026-09-11',
|
||||
],
|
||||
'京东-俚库节华专卖店' => [
|
||||
'previous_company' => '常州淘之牧家居有限公司',
|
||||
'company' => '武汉市跨星河贸易有限公司',
|
||||
'changed_at' => '2026-09-10',
|
||||
],
|
||||
'京东-全颐家具旗舰店' => [
|
||||
'previous_company' => '常州酷积吉电子商务有限公司',
|
||||
'company' => '深圳市匠师科技有限公司',
|
||||
'changed_at' => '2026-07-30',
|
||||
],
|
||||
'京东-励树旗舰店' => [
|
||||
'previous_company' => '常州在木居家居有限公司',
|
||||
'company' => '珠海市澄澄成贸易有限公司',
|
||||
'changed_at' => '2026-08-05',
|
||||
],
|
||||
'京东-半隅雅居旗舰店' => [
|
||||
'previous_company' => '常州尚之荷电子商务有限公司',
|
||||
'company' => '广州市椿风贸易有限公司',
|
||||
'changed_at' => '2026-07-27',
|
||||
],
|
||||
'京东-卓禾客厅家具旗舰店' => [
|
||||
'previous_company' => '常州幂佳电子商务有限公司',
|
||||
'company' => '广州市椿风贸易有限公司',
|
||||
'changed_at' => '2026-08-25',
|
||||
],
|
||||
'京东-卓禾桌旗舰店' => [
|
||||
'previous_company' => '常州素柚电子商务有限公司',
|
||||
'company' => '武汉市焕独树科技有限公司',
|
||||
'changed_at' => '2026-09-11',
|
||||
],
|
||||
'京东-墨尚思家具官方旗舰店' => [
|
||||
'previous_company' => '常州米之信家居有限公司',
|
||||
'company' => '深圳市澄镜贸易有限公司',
|
||||
'changed_at' => '2026-08-05',
|
||||
],
|
||||
'京东-宛初家具旗舰店' => [
|
||||
'previous_company' => '常州迈之时家居有限公司',
|
||||
'company' => '武汉市跨星河贸易有限公司',
|
||||
'changed_at' => '2026-09-28',
|
||||
],
|
||||
'京东-宜造旗舰店' => [
|
||||
'previous_company' => '常州泰赫家居有限公司',
|
||||
'company' => '武汉市鼎新致远科技有限公司',
|
||||
'changed_at' => '2026-07-24',
|
||||
],
|
||||
'京东-左印卧室家具旗舰店' => [
|
||||
'previous_company' => '常州格之简电子商务有限公司',
|
||||
'company' => '广州市椿风贸易有限公司',
|
||||
'changed_at' => '2026-07-14',
|
||||
],
|
||||
'京东-左印旗舰店' => [
|
||||
'previous_company' => '常州正琳电子商务有限公司',
|
||||
'company' => '义乌市答馥贸易有限公司',
|
||||
'changed_at' => '2026-09-11',
|
||||
],
|
||||
'京东-巷予家具旗舰店' => [
|
||||
'previous_company' => '常州鹏冒家居有限公司',
|
||||
'company' => '株洲市晞飏盛商贸有限公司',
|
||||
'changed_at' => '2026-08-05',
|
||||
],
|
||||
'京东-新卓禾旗舰店' => [
|
||||
'previous_company' => '常州仁之云家居有限公司',
|
||||
'company' => '广州市椿风贸易有限公司',
|
||||
'changed_at' => '2026-07-14',
|
||||
],
|
||||
'京东-木本诗家具旗舰店' => [
|
||||
'previous_company' => '常州瓦铭家居有限公司',
|
||||
'company' => '深圳市匠师科技有限公司',
|
||||
'changed_at' => '2026-08-03',
|
||||
],
|
||||
'京东-未时旗舰店' => [
|
||||
'previous_company' => '常州和之非家居有限公司',
|
||||
'company' => '义乌市答馥贸易有限公司',
|
||||
'changed_at' => '2026-07-24',
|
||||
],
|
||||
'京东-柯诺苏家具旗舰店' => [
|
||||
'previous_company' => '常州旭致家居有限公司',
|
||||
'company' => '株洲市晞飏盛商贸有限公司',
|
||||
'changed_at' => '2026-08-05',
|
||||
],
|
||||
'京东-橡野里家具小店' => [
|
||||
'previous_company' => '武进区潞城木易家具店',
|
||||
'company' => '武汉市焕独树科技有限公司',
|
||||
'changed_at' => '2026-07-09',
|
||||
],
|
||||
'京东-爱家猫' => [
|
||||
'previous_company' => '常州峰麟电子商务有限公司',
|
||||
'company' => '广州市椿风贸易有限公司',
|
||||
'changed_at' => '2026-07-14',
|
||||
],
|
||||
'京东-爱木儿旗舰店' => [
|
||||
'previous_company' => '常州泽之云家居有限公司',
|
||||
'company' => '广州市椿风贸易有限公司',
|
||||
'changed_at' => '2026-07-28',
|
||||
],
|
||||
'京东-礼想木旗舰店' => [
|
||||
'previous_company' => '常州歆之吉家居有限公司',
|
||||
'company' => '武汉市金逐玉贸易有限公司',
|
||||
'changed_at' => '2026-08-15',
|
||||
],
|
||||
'京东-第一森林客厅家具旗舰店' => [
|
||||
'previous_company' => '常州连音电子商务有限公司',
|
||||
'company' => '广州芯喻科技有限公司',
|
||||
'changed_at' => '2026-08-04',
|
||||
],
|
||||
'京东-第一森林家具旗舰店' => [
|
||||
'previous_company' => '常州旭可电子商务有限公司',
|
||||
'company' => '义乌市答馥贸易有限公司',
|
||||
'changed_at' => '2026-07-27',
|
||||
],
|
||||
'京东-米客米家家具旗舰店' => [
|
||||
'previous_company' => '常州欧鹰朗家居有限公司',
|
||||
'company' => '上海臻磊居电子商务有限公司',
|
||||
'changed_at' => '2026-09-10',
|
||||
],
|
||||
'京东-米昊旗舰店' => [
|
||||
'previous_company' => '常州州圣家居有限公司',
|
||||
'company' => '义乌市育旬贸易有限公司',
|
||||
'changed_at' => '2026-07-30',
|
||||
],
|
||||
'京东-素成木旗舰店' => [
|
||||
'previous_company' => '常州泱涵家居有限公司',
|
||||
'company' => '武汉市金逐玉贸易有限公司',
|
||||
'changed_at' => '2026-09-11',
|
||||
],
|
||||
'京东-纪辰家具旗舰店' => [
|
||||
'previous_company' => '常州念晶家居有限公司',
|
||||
'company' => '杭州照萍芋贸易有限公司',
|
||||
'changed_at' => '2026-09-14',
|
||||
],
|
||||
'京东-贝壳家具旗舰店' => [
|
||||
'previous_company' => '常州致圣家居有限公司',
|
||||
'company' => '常州研选供应链有限公司',
|
||||
'changed_at' => '2026-08-03',
|
||||
],
|
||||
'京东-赛初家具旗舰店' => [
|
||||
'previous_company' => '常州嘉之钰家居有限公司',
|
||||
'company' => '武汉市焕独树科技有限公司',
|
||||
'changed_at' => '2026-09-11',
|
||||
],
|
||||
'京东-首升瓦舟迪专卖店' => [
|
||||
'previous_company' => '常州绒灿家居有限公司',
|
||||
'company' => '株洲市棱斛盛商贸有限公司',
|
||||
'changed_at' => '2026-08-05',
|
||||
],
|
||||
'抖音-乾廷润凯家具专卖店' => [
|
||||
'previous_company' => '常州迈而昕家居有限公司',
|
||||
'company' => '武汉市焕独树科技有限公司',
|
||||
'changed_at' => '2026-07-24',
|
||||
],
|
||||
'抖音-亿家达官方旗舰店' => [
|
||||
'previous_company' => '常州格之简电子商务有限公司',
|
||||
'company' => '广州市椿风贸易有限公司',
|
||||
'changed_at' => '2026-07-22',
|
||||
],
|
||||
'抖音-亿家达家居专卖店' => [
|
||||
'previous_company' => '常州庆之图家居有限公司',
|
||||
'company' => '杭州背多爱贸易有限公司',
|
||||
'changed_at' => '2026-08-24',
|
||||
],
|
||||
'抖音-佰汇家具店' => [
|
||||
'previous_company' => '常州巴宣家居有限公司',
|
||||
'company' => '武汉市跨星河贸易有限公司',
|
||||
'changed_at' => '2026-09-10',
|
||||
],
|
||||
'抖音-凌木家具店' => [
|
||||
'previous_company' => '常州马月家居有限公司',
|
||||
'company' => '武汉市焕独树科技有限公司',
|
||||
'changed_at' => '2026-07-17',
|
||||
],
|
||||
'抖音-务原家具店' => [
|
||||
'previous_company' => '常州格之麦家居有限公司',
|
||||
'company' => '深圳市宙桦奇贸易有限公司',
|
||||
'changed_at' => '2026-08-04',
|
||||
],
|
||||
'抖音-勤松苏得荷家具专卖店' => [
|
||||
'previous_company' => '常州米之信家居有限公司',
|
||||
'company' => '上海禄美佳商贸有限公司',
|
||||
'changed_at' => '2026-07-26',
|
||||
],
|
||||
'抖音-吉友爱塑荣家居专卖店' => [
|
||||
'previous_company' => '常州洁才家居有限公司',
|
||||
'company' => '深圳市鑫鑫向洋科技有限公司',
|
||||
'changed_at' => '2026-08-24',
|
||||
],
|
||||
'抖音-和辉家具店' => [
|
||||
'previous_company' => '常州阳之红家居有限公司',
|
||||
'company' => '武汉市跨星河贸易有限公司',
|
||||
'changed_at' => '2026-09-07',
|
||||
],
|
||||
'抖音-常州科斯能家居有限公司' => [
|
||||
'previous_company' => '常州俪能家居有限公司',
|
||||
'company' => '义乌市络翘贸易有限公司',
|
||||
'changed_at' => '2026-08-04',
|
||||
],
|
||||
'抖音-德朗蒂家具店' => [
|
||||
'previous_company' => '常州蓝瞳家居有限公司',
|
||||
'company' => '广州市墨拓贸易有限公司',
|
||||
'changed_at' => '2026-07-17',
|
||||
],
|
||||
'抖音-星程家具店' => [
|
||||
'previous_company' => '常州初之语家居有限公司',
|
||||
'company' => '株洲市晞飏盛商贸有限公司',
|
||||
'changed_at' => '2026-08-03',
|
||||
],
|
||||
'抖音-林舸家具店' => [
|
||||
'previous_company' => '常州迈之时家居有限公司',
|
||||
'company' => '珠海市澄澄成贸易有限公司',
|
||||
'changed_at' => '2026-07-24',
|
||||
],
|
||||
'抖音-柯诺苏来导专卖店' => [
|
||||
'previous_company' => '常州瓦铭家居有限公司',
|
||||
'company' => '珠海市澄澄成贸易有限公司',
|
||||
'changed_at' => '2026-07-24',
|
||||
],
|
||||
'抖音-柯诺苏艾利华家具专卖店' => [
|
||||
'previous_company' => '常州初之语家居有限公司',
|
||||
'company' => '武汉市跨星河贸易有限公司',
|
||||
'changed_at' => '2026-09-07',
|
||||
],
|
||||
'抖音-樛木家具店' => [
|
||||
'previous_company' => '常州绒美家居有限公司',
|
||||
'company' => '深圳市澄镜贸易有限公司',
|
||||
'changed_at' => '2026-08-03',
|
||||
],
|
||||
'抖音-畅森家具店' => [
|
||||
'previous_company' => '常州茶木家居有限公司',
|
||||
'company' => '义乌市答馥贸易有限公司',
|
||||
'changed_at' => '2026-08-10',
|
||||
],
|
||||
'抖音-素屑家塑荣家具专卖店' => [
|
||||
'previous_company' => '常州洁才家居有限公司',
|
||||
'company' => '珠海市澄澄成贸易有限公司',
|
||||
'changed_at' => '2026-07-24',
|
||||
],
|
||||
'抖音-纪辰欢而专卖店' => [
|
||||
'previous_company' => '常州幂佳电子商务有限公司',
|
||||
'company' => '广州市鼎流拓科技有限公司',
|
||||
'changed_at' => '2026-07-24',
|
||||
],
|
||||
'抖音-艺煦白墙专卖店' => [
|
||||
'previous_company' => '常州梦彬家居有限公司',
|
||||
'company' => '深圳市澄镜贸易有限公司',
|
||||
'changed_at' => '2026-08-04',
|
||||
],
|
||||
'抖音-蓝悦家具店' => [
|
||||
'previous_company' => '常州初之语家居有限公司',
|
||||
'company' => '株洲市晞飏盛商贸有限公司',
|
||||
'changed_at' => '2026-08-03',
|
||||
],
|
||||
'抖音-赛初家具旗舰店' => [
|
||||
'previous_company' => '常州绒美家居有限公司',
|
||||
'company' => '义乌市育旬贸易有限公司',
|
||||
'changed_at' => '2026-08-04',
|
||||
],
|
||||
'抖音-赛初莱夫特家具专卖店' => [
|
||||
'previous_company' => '常州义之迈电子商务有限公司',
|
||||
'company' => '珠海市澄澄成贸易有限公司',
|
||||
'changed_at' => '2026-07-24',
|
||||
],
|
||||
'抖音-越堂家具店' => [
|
||||
'previous_company' => '常州幂佳电子商务有限公司',
|
||||
'company' => '广州市鼎流拓科技有限公司',
|
||||
'changed_at' => '2026-07-24',
|
||||
],
|
||||
'拼多多-UCU官方旗舰店' => [
|
||||
'previous_company' => '常州格之简电子商务有限公司',
|
||||
'company' => '青岛婳欣妍商贸有限公司',
|
||||
'changed_at' => '2026-09-14',
|
||||
],
|
||||
'拼多多-久伴吾家具旗舰店' => [
|
||||
'previous_company' => '常州格之简电子商务有限公司',
|
||||
'company' => '武汉市福流贸易有限公司',
|
||||
'changed_at' => '2026-07-17',
|
||||
],
|
||||
'拼多多-亿佳木住宅家具官方旗舰店' => [
|
||||
'previous_company' => '常州洁秋家居有限公司',
|
||||
'company' => '杭州背多爱贸易有限公司',
|
||||
'changed_at' => '2026-07-16',
|
||||
],
|
||||
'拼多多-亿家木点好多专卖店' => [
|
||||
'previous_company' => '常州欣之露电子商务有限公司',
|
||||
'company' => '上海臻潭寨电子商务有限公司',
|
||||
'changed_at' => '2026-09-10',
|
||||
],
|
||||
'拼多多-亿家达书房家具官方旗舰店' => [
|
||||
'previous_company' => '常州连音电子商务有限公司',
|
||||
'company' => '武汉市鼎新致远科技有限公司',
|
||||
'changed_at' => '2026-07-15',
|
||||
],
|
||||
'拼多多-亿家达住宅家具官方旗舰店' => [
|
||||
'previous_company' => '常州悠益电子商务有限公司',
|
||||
'company' => '武汉市鼎新致远科技有限公司',
|
||||
'changed_at' => '2026-07-17',
|
||||
],
|
||||
'拼多多-亿家达卧室家具官方旗舰店' => [
|
||||
'previous_company' => '常州锋翎家居有限公司',
|
||||
'company' => '武汉市鼎新致远科技有限公司',
|
||||
'changed_at' => '2026-07-15',
|
||||
],
|
||||
'拼多多-亿家达官方旗舰店' => [
|
||||
'previous_company' => '常州连音电子商务有限公司',
|
||||
'company' => '武汉市鼎新致远科技有限公司',
|
||||
'changed_at' => '2026-07-16',
|
||||
],
|
||||
'拼多多-亿家达实木家具官方旗舰店' => [
|
||||
'previous_company' => '常州连音电子商务有限公司',
|
||||
'company' => '武汉市鼎新致远科技有限公司',
|
||||
'changed_at' => '2026-07-15',
|
||||
],
|
||||
'拼多多-亿家达客厅家具官方旗舰店' => [
|
||||
'previous_company' => '常州锋翎家居有限公司',
|
||||
'company' => '武汉市鼎新致远科技有限公司',
|
||||
'changed_at' => '2026-07-15',
|
||||
],
|
||||
'拼多多-亿家达旗舰店' => [
|
||||
'previous_company' => '常州锋翎家居有限公司',
|
||||
'company' => '青岛哲晖义商贸有限公司',
|
||||
'changed_at' => '2026-09-10',
|
||||
],
|
||||
'拼多多-亿家达桌椅官方旗舰店' => [
|
||||
'previous_company' => '常州锋翎家居有限公司',
|
||||
'company' => '武汉市鼎新致远科技有限公司',
|
||||
'changed_at' => '2026-07-15',
|
||||
],
|
||||
'拼多多-优乐熊家具旗舰店' => [
|
||||
'previous_company' => '常州旭可电子商务有限公司',
|
||||
'company' => '深圳市鑫鑫向洋科技有限公司',
|
||||
'changed_at' => '2026-07-15',
|
||||
],
|
||||
'拼多多-优乐猫家具旗舰店' => [
|
||||
'previous_company' => '常州君之兰家居有限公司',
|
||||
'company' => '义乌市竺芽电子商务有限公司',
|
||||
'changed_at' => '2026-07-24',
|
||||
],
|
||||
'拼多多-全米家具旗舰店' => [
|
||||
'previous_company' => '常州酷积吉电子商务有限公司',
|
||||
'company' => '武汉市福流贸易有限公司',
|
||||
'changed_at' => '2026-07-15',
|
||||
],
|
||||
'拼多多-华斯乐畅专卖店' => [
|
||||
'previous_company' => '常州云栀电子商务有限公司',
|
||||
'company' => '武汉市跨星河贸易有限公司',
|
||||
'changed_at' => '2026-09-10',
|
||||
],
|
||||
'拼多多-华斯予之福专卖店' => [
|
||||
'previous_company' => '常州予之福家居有限公司',
|
||||
'company' => '上海臻潭寨电子商务有限公司',
|
||||
'changed_at' => '2026-09-10',
|
||||
],
|
||||
'拼多多-卓禾住宅家具旗舰店' => [
|
||||
'previous_company' => '常州歆森电子商务有限公司',
|
||||
'company' => '安阳县峋庭电子商务有限公司',
|
||||
'changed_at' => '2026-08-14',
|
||||
],
|
||||
'拼多多-卓禾家纺家具家装旗舰店' => [
|
||||
'previous_company' => '常州庆之图家居有限公司',
|
||||
'company' => '杭州背多爱贸易有限公司',
|
||||
'changed_at' => '2026-07-24',
|
||||
],
|
||||
'拼多多-卓禾家纺家具旗舰店' => [
|
||||
'previous_company' => '常州笛莫电子商务有限公司',
|
||||
'company' => '安阳县峋庭电子商务有限公司',
|
||||
'changed_at' => '2026-08-14',
|
||||
],
|
||||
'拼多多-卓禾旗舰店' => [
|
||||
'previous_company' => '常州庆之图家居有限公司',
|
||||
'company' => '杭州背多爱贸易有限公司',
|
||||
'changed_at' => '2026-08-12',
|
||||
],
|
||||
'拼多多-哈利木丫官方旗舰店' => [
|
||||
'previous_company' => '常州旭可电子商务有限公司',
|
||||
'company' => '深圳市鑫鑫向洋科技有限公司',
|
||||
'changed_at' => '2026-07-24',
|
||||
],
|
||||
'拼多多-墨尚思家具旗舰店' => [
|
||||
'previous_company' => '常州苡宝家居有限公司',
|
||||
'company' => '武汉市福流贸易有限公司',
|
||||
'changed_at' => '2026-07-14',
|
||||
],
|
||||
'拼多多-宜卡木住宅家具旗舰店' => [
|
||||
'previous_company' => '常州书心家居有限公司',
|
||||
'company' => '青岛婳欣妍商贸有限公司',
|
||||
'changed_at' => '2026-08-24',
|
||||
],
|
||||
'拼多多-宜自然住宅家具旗舰店' => [
|
||||
'previous_company' => '常州笛莫电子商务有限公司',
|
||||
'company' => '深圳市澜祺科技有限公司',
|
||||
'changed_at' => '2026-07-16',
|
||||
],
|
||||
'拼多多-宜造家具旗舰店' => [
|
||||
'previous_company' => '常州州圣家居有限公司',
|
||||
'company' => '深圳市澄镜贸易有限公司',
|
||||
'changed_at' => '2026-07-16',
|
||||
],
|
||||
'拼多多-屿林间家具旗舰店' => [
|
||||
'previous_company' => '常州欣之露电子商务有限公司',
|
||||
'company' => '广州市墨拓贸易有限公司',
|
||||
'changed_at' => '2026-07-15',
|
||||
],
|
||||
'拼多多-左印家具旗舰店' => [
|
||||
'previous_company' => '常州歆森电子商务有限公司',
|
||||
'company' => '青岛婳欣妍商贸有限公司',
|
||||
'changed_at' => '2026-07-17',
|
||||
],
|
||||
'拼多多-左昊家具旗舰店' => [
|
||||
'previous_company' => '常州青号电子商务有限公司',
|
||||
'company' => '深圳市匠师科技有限公司',
|
||||
'changed_at' => '2026-07-24',
|
||||
],
|
||||
'拼多多-归来舍下住宅家具旗舰店' => [
|
||||
'previous_company' => '常州凯之贝家居有限公司',
|
||||
'company' => '青岛铧胜靖商贸有限公司',
|
||||
'changed_at' => '2026-09-10',
|
||||
],
|
||||
'拼多多-惠木家特坤森专卖店' => [
|
||||
'previous_company' => '常州冬之实家居有限公司',
|
||||
'company' => '义乌市竺芽电子商务有限公司',
|
||||
'changed_at' => '2026-07-24',
|
||||
],
|
||||
'拼多多-慕加家具旗舰店' => [
|
||||
'previous_company' => '常州青号电子商务有限公司',
|
||||
'company' => '青岛哲晖义商贸有限公司',
|
||||
'changed_at' => '2026-09-14',
|
||||
],
|
||||
'拼多多-星空夏日家纺家具家装官方旗舰店' => [
|
||||
'previous_company' => '常州山之海家居有限公司',
|
||||
'company' => '青岛婳欣妍商贸有限公司',
|
||||
'changed_at' => '2026-07-15',
|
||||
],
|
||||
'拼多多-木小乐住宅家具旗舰店' => [
|
||||
'previous_company' => '常州烟之雨家居有限公司',
|
||||
'company' => '青岛疆拓杰商贸有限公司',
|
||||
'changed_at' => '2026-09-10',
|
||||
],
|
||||
'拼多多-木庭乐家具官方旗舰店' => [
|
||||
'previous_company' => '常州赫晟电子商务有限公司',
|
||||
'company' => '广州芯喻科技有限公司',
|
||||
'changed_at' => '2026-07-13',
|
||||
],
|
||||
'拼多多-木野拾趣家具官方旗舰店' => [
|
||||
'previous_company' => '常州笛莫电子商务有限公司',
|
||||
'company' => '深圳市鑫鑫向洋科技有限公司',
|
||||
'changed_at' => '2026-07-14',
|
||||
],
|
||||
'拼多多-橡野里家具旗舰店' => [
|
||||
'previous_company' => '常州橙兮电子商务有限公司',
|
||||
'company' => '武汉市朵莹米贸易有限公司',
|
||||
'changed_at' => '2026-07-17',
|
||||
],
|
||||
'拼多多-橡野里纳熙专卖店' => [
|
||||
'previous_company' => '常州仁之云家居有限公司',
|
||||
'company' => '武汉市福流贸易有限公司',
|
||||
'changed_at' => '2026-07-14',
|
||||
],
|
||||
'拼多多-欧意朗住宅家具旗舰店' => [
|
||||
'previous_company' => '常州羽之间电子商务有限公司',
|
||||
'company' => '安阳县峋庭电子商务有限公司',
|
||||
'changed_at' => '2026-08-12',
|
||||
],
|
||||
'拼多多-欧意朗办公家具旗舰店' => [
|
||||
'previous_company' => '常州莱夫特家居有限公司',
|
||||
'company' => '安阳县峋庭电子商务有限公司',
|
||||
'changed_at' => '2026-08-12',
|
||||
],
|
||||
'拼多多-欧意朗家具官方旗舰店' => [
|
||||
'previous_company' => '常州森静电子商务有限公司',
|
||||
'company' => '杭州背多爱贸易有限公司',
|
||||
'changed_at' => '2026-08-12',
|
||||
],
|
||||
'拼多多-欧意朗生活家具旗舰店' => [
|
||||
'previous_company' => '常州锋翎家居有限公司',
|
||||
'company' => '安阳县峋庭电子商务有限公司',
|
||||
'changed_at' => '2026-08-12',
|
||||
],
|
||||
'拼多多-润万卓家具旗舰店' => [
|
||||
'previous_company' => '常州平灿家居有限公司',
|
||||
'company' => '武汉市福流贸易有限公司',
|
||||
'changed_at' => '2026-07-24',
|
||||
],
|
||||
'拼多多-润典达旗舰店' => [
|
||||
'previous_company' => '常州橙兮电子商务有限公司',
|
||||
'company' => '上海禄美佳商贸有限公司',
|
||||
'changed_at' => '2026-07-16',
|
||||
],
|
||||
'拼多多-满富油家具旗舰店' => [
|
||||
'previous_company' => '常州皓熠电子商务有限公司',
|
||||
'company' => '深圳市宙桦奇贸易有限公司',
|
||||
'changed_at' => '2026-09-10',
|
||||
],
|
||||
'拼多多-爱木儿家具旗舰店' => [
|
||||
'previous_company' => '常州艺之瑾家居有限公司',
|
||||
'company' => '南召县栎安商贸有限公司',
|
||||
'changed_at' => '2026-09-10',
|
||||
],
|
||||
'拼多多-百里香晟陶专卖店' => [
|
||||
'previous_company' => '常州晟陶电子商务有限公司',
|
||||
'company' => '青岛铧胜靖商贸有限公司',
|
||||
'changed_at' => '2026-09-14',
|
||||
],
|
||||
'拼多多-礼想木家具旗舰店' => [
|
||||
'previous_company' => '常州雅之荷电子商务有限公司',
|
||||
'company' => '武汉市朵莹米贸易有限公司',
|
||||
'changed_at' => '2026-07-14',
|
||||
],
|
||||
'拼多多-第一森林旗舰店' => [
|
||||
'previous_company' => '常州思温家居有限公司',
|
||||
'company' => '杭州照萍芋贸易有限公司',
|
||||
'changed_at' => '2026-07-14',
|
||||
],
|
||||
'拼多多-米昊家具旗舰店' => [
|
||||
'previous_company' => '常州素柚电子商务有限公司',
|
||||
'company' => '青岛婳欣妍商贸有限公司',
|
||||
'changed_at' => '2026-07-30',
|
||||
],
|
||||
'拼多多-美栖家家具官方旗舰店' => [
|
||||
'previous_company' => '常州州圣家居有限公司',
|
||||
'company' => '深圳市匠师科技有限公司',
|
||||
'changed_at' => '2026-07-14',
|
||||
],
|
||||
'拼多多-花木涧家具旗舰店' => [
|
||||
'previous_company' => '常州盈橡家居有限公司',
|
||||
'company' => '杭州背多爱贸易有限公司',
|
||||
'changed_at' => '2026-09-14',
|
||||
],
|
||||
'拼多多-莫埃家具旗舰店' => [
|
||||
'previous_company' => '常州昀禾家居有限公司',
|
||||
'company' => '青岛婳欣妍商贸有限公司',
|
||||
'changed_at' => '2026-07-15',
|
||||
],
|
||||
'拼多多-迈磊凯家具专营店' => [
|
||||
'previous_company' => '常州目之本电子商务有限公司',
|
||||
'company' => '武汉市福流贸易有限公司',
|
||||
'changed_at' => '2026-07-16',
|
||||
],
|
||||
'淘宝-平提家具源头工厂' => [
|
||||
'previous_company' => '常州平提电子商务有限公司',
|
||||
'company' => '义乌市育旬贸易有限公司',
|
||||
'changed_at' => '2026-08-20',
|
||||
],
|
||||
'淘宝-康帕斯家具店' => [
|
||||
'previous_company' => '永康市康帕斯贸易有限公司',
|
||||
'company' => '深圳市澄镜贸易有限公司',
|
||||
'changed_at' => '2026-08-20',
|
||||
],
|
||||
'淘宝-知木家具源头工厂店' => [
|
||||
'previous_company' => '常州聪恩家居有限公司',
|
||||
'company' => '广州市鼎流拓科技有限公司',
|
||||
'changed_at' => '2026-08-14',
|
||||
],
|
||||
'阿里巴巴-达轩家居' => [
|
||||
'previous_company' => '常州末屿家居有限公司',
|
||||
'company' => '上海臻希鹏电子商务有限公司',
|
||||
'changed_at' => '2026-09-24',
|
||||
],
|
||||
],
|
||||
];
|
||||
72
config/store_violations.php
Normal file
72
config/store_violations.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 店铺违规信息预警:统一表头与映射
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 页面、导入、导出共用同一套列顺序和字段键;各平台上游/导出文件的表头
|
||||
| 通过 header_aliases 映射到统一列。违规分类按 category_rules 关键词
|
||||
| 从上到下匹配,未命中归入 default_category。
|
||||
*/
|
||||
|
||||
return [
|
||||
// 统一表头(顺序即页面、导出、导入模板的列顺序)
|
||||
'columns' => [
|
||||
['key' => 'shop_name', 'label' => '店铺名'],
|
||||
['key' => 'violation_type', 'label' => '违规类型'],
|
||||
['key' => 'violation_category', 'label' => '违规分类'],
|
||||
['key' => 'violation_no', 'label' => '违规单号'],
|
||||
['key' => 'violation_date', 'label' => '违规日期'],
|
||||
['key' => 'points_deducted', 'label' => '扣分'],
|
||||
['key' => 'status', 'label' => '状态'],
|
||||
['key' => 'penalty_date', 'label' => '处罚日期'],
|
||||
['key' => 'appeal_deadline', 'label' => '申诉截止日期'],
|
||||
['key' => 'handling_status', 'label' => '处理状态'],
|
||||
],
|
||||
|
||||
// 页面平台页签;source_values 为上游/导入文件中平台字段的取值
|
||||
'platforms' => [
|
||||
'taobao' => ['label' => '淘系', 'icon' => 'shopping_bag', 'source_values' => ['淘宝', '天猫', '淘系', 'taobao', 'tmall']],
|
||||
'alibaba' => ['label' => '阿里巴巴', 'icon' => 'inventory_2', 'source_values' => ['阿里巴巴', '1688', 'alibaba']],
|
||||
'jd' => ['label' => '京东', 'icon' => 'local_mall', 'source_values' => ['京东', 'jd', 'jingdong']],
|
||||
'jingxi' => ['label' => '京喜', 'icon' => 'storefront', 'source_values' => ['京喜', 'jingxi']],
|
||||
'pinduoduo' => ['label' => '拼多多', 'icon' => 'grid_view', 'source_values' => ['拼多多', 'pdd', 'pinduoduo']],
|
||||
'douyin' => ['label' => '抖音', 'icon' => 'play_circle', 'source_values' => ['抖音', 'douyin']],
|
||||
],
|
||||
|
||||
// 处理状态(本地维护)
|
||||
'handling_statuses' => ['未处理', '处理中', '已处理', '无需处理'],
|
||||
'default_handling_status' => '未处理',
|
||||
|
||||
// 导入表头别名 → 统一列键(不区分大小写、忽略空格)
|
||||
'header_aliases' => [
|
||||
'shop_name' => ['店铺名', '店铺名称', '店铺', '商家名称', '商家', 'shop_name', 'shopname'],
|
||||
'violation_type' => ['违规类型', '违规内容', '违规行为', '违规原因', '违规描述', 'violation_type'],
|
||||
'violation_category' => ['违规分类', '违规类别', '分类', 'violation_category'],
|
||||
'violation_no' => ['违规单号', '违规编号', '违规id', '违规ID', '单号', 'violation_no'],
|
||||
'violation_date' => ['违规日期', '违规时间', '违规发生时间', 'violation_date'],
|
||||
'points_deducted' => ['扣分', '扣分数', '扣除分数', '罚分', 'points_deducted'],
|
||||
'status' => ['状态', '平台状态', '处罚状态', '违规状态', 'status'],
|
||||
'penalty_date' => ['处罚日期', '处罚时间', 'penalty_date'],
|
||||
'appeal_deadline' => ['申诉截止日期', '申诉截止时间', '申诉期限', '申诉截止', 'appeal_deadline'],
|
||||
'handling_status' => ['处理状态', '我方处理状态', '处理进度', 'handling_status'],
|
||||
],
|
||||
|
||||
// 违规分类规则:命中顺序从上到下
|
||||
'category_rules' => [
|
||||
'发货履约' => ['发货', '缺货', '无货', '揽收', '出库', '延迟', '物流轨迹'],
|
||||
'物流异常' => ['物流', '轨迹', '签收', '少件', '错件', '快递', '虚假轨迹'],
|
||||
'交易违规' => ['虚假交易', '销量', '评价', '虚假发货', '刷单', '虚假服务'],
|
||||
'商品合规' => ['商品属性', '商品信息', '质量', '劣质', '不合格', '标题', '卖点', '描述不符', '空包'],
|
||||
'服务体验' => ['服务', '投诉', '差评', '售后', '客服', '负面反馈'],
|
||||
'发票与考核' => ['发票', '考核', '工单', '及时率', '上传率'],
|
||||
],
|
||||
'default_category' => '其他',
|
||||
|
||||
// 导入限制
|
||||
'import' => [
|
||||
'max_rows' => 20000,
|
||||
'max_kilobytes' => 10240,
|
||||
],
|
||||
];
|
||||
@ -87,8 +87,8 @@ return [
|
||||
'overlap_rows' => (int) env('STORE_REPORT_MIRROR_OVERLAP_ROWS', 50000),
|
||||
],
|
||||
|
||||
// 聚水潭进销存报表:固定只读“三方-南昌樵舍仓”,页面只读共享镜像。
|
||||
// 头表按 Id 区间小批读取并固定仓库,避免对上游流水表做无界扫描。
|
||||
// 聚水潭进销存共享流水镜像:头表按 Id 区间小批读取全部仓库。
|
||||
// 三方仓页面在 Repository 层继续固定南昌樵舍仓,进销存报表按全仓库汇总。
|
||||
'third_party_warehouse_storage' => [
|
||||
'dataset_key' => 'jiansun.third-party-warehouse-storage',
|
||||
'source_key' => 'jiansun',
|
||||
@ -147,6 +147,11 @@ return [
|
||||
'stale_after_minutes' => (int) env('COURIER_INFORMATION_MIRROR_STALE_MINUTES', 60),
|
||||
],
|
||||
|
||||
// 工作平台退货取件管理:客服取件单按天更新,允许比快递资料更长的同步间隔。
|
||||
'return_pickup' => [
|
||||
'stale_after_minutes' => (int) env('RETURN_PICKUP_MIRROR_STALE_MINUTES', 180),
|
||||
],
|
||||
|
||||
// 工作平台采购价格按 SKU 补充到普通商品资料;页面只读取共享镜像。
|
||||
'work_platform_prices' => [
|
||||
'dataset_key' => 'jiansun.dbo.pm_priceAdjustment',
|
||||
@ -187,6 +192,18 @@ return [
|
||||
'operations_replica.rpt.fys_companyInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_companyInfo', 'target' => 'ops_fys_company_info', 'keys' => ['Id'], 'watermark' => 'updateTIme'],
|
||||
'operations_replica.rpt.fys_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_shopInfo', 'target' => 'ops_fys_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
|
||||
'operations_replica.rpt.fys_TrademarkInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_TrademarkInfo', 'target' => 'ops_fys_trademark_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
|
||||
// 店铺后台每天抓取的店铺→主体映射:店铺维度报表用它识别主体接替并生成新期间。
|
||||
'operations_replica.rpt.crawl_shop_company' => [
|
||||
'source_key' => 'operations_replica',
|
||||
'schema' => 'rpt',
|
||||
'object' => 'crawl_shop_company',
|
||||
'target' => 'ops_crawl_shop_company',
|
||||
'keys' => ['id'],
|
||||
'watermark' => 'update_time',
|
||||
'indexes' => [
|
||||
['shop_name', 'crawl_date', 'id'],
|
||||
],
|
||||
],
|
||||
'operations_replica.rpt.jst_Orders' => [
|
||||
'source_key' => 'operations_replica',
|
||||
'schema' => 'rpt',
|
||||
@ -198,7 +215,9 @@ return [
|
||||
['o_id', 'Id'],
|
||||
['order_date', 'o_id', 'Id'],
|
||||
// 账单明细按线上订单号 so_id 关联订单表;缺这个索引会让关联退化成百万行全表扫描。
|
||||
['so_id', 'Id'],
|
||||
// SQL Server 的历史 so_id 字段可能超过 MariaDB 联合索引上限;
|
||||
// 订单号关联使用前 64 个字符已覆盖实际单号格式,同时保留 Id 唯一定位。
|
||||
['so_id(64)', 'Id'],
|
||||
],
|
||||
],
|
||||
'operations_replica.rpt.jst_OrderItem' => [
|
||||
@ -222,6 +241,7 @@ return [
|
||||
['sku_type', 'autoid'],
|
||||
['sku_type', 'i_id', 'autoid'],
|
||||
['sku_type', 'sku_id', 'autoid'],
|
||||
['sku_id', 'autoid'],
|
||||
['sku_type', 'enabled', 'autoid'],
|
||||
['sku_type', 'stock_disabled', 'autoid'],
|
||||
],
|
||||
@ -436,6 +456,31 @@ return [
|
||||
['fee_name'],
|
||||
],
|
||||
],
|
||||
// 聚水潭得物货款账户:复制主账单、退货退款和其他费用扣减渠道;
|
||||
// 保证金及其他账户域流水不混入店铺收款账户页面。
|
||||
'jiansun.dbo.jst_originalBillInfo.dewu_receiving' => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
'object' => 'jst_originalBillInfo',
|
||||
'target' => 'js_jst_dewu_receiving_bill_rows',
|
||||
'keys' => ['Id'],
|
||||
'watermark' => 'updateTime',
|
||||
'fixed_filters' => [
|
||||
'channel' => ['Dewu', 'DewuTK', 'DewuKJ'],
|
||||
],
|
||||
'columns' => [
|
||||
'Id', 'shop_id', 'channel', 'fee_time', 'del_flag', 'primary_key',
|
||||
'createTime', 'updateTime', 'auto_id', 'fee_name', 'inout_type',
|
||||
'oc_amount', 'p_auto_id', 'shop_i_id', 'shop_sku_id', 'sku_id',
|
||||
'so_id', 'combine_type', 'amount',
|
||||
],
|
||||
'indexes' => [
|
||||
['Id'],
|
||||
['fee_time', 'Id', 'inout_type', 'amount'],
|
||||
['shop_id', 'fee_time', 'Id', 'inout_type', 'amount', 'fee_name'],
|
||||
['fee_name'],
|
||||
],
|
||||
],
|
||||
'jiansun.dbo.alipay_Bill_Detail' => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
@ -600,5 +645,94 @@ return [
|
||||
'keys' => ['Id'],
|
||||
'watermark' => 'modifyTime',
|
||||
],
|
||||
|
||||
// 工作平台 2.0:客服系统 → 退款数据 → 退货取件管理(主表 + 明细 + 实退快递单号映射)。
|
||||
// 逆向物流运费核算与退件跟踪页面只读取共享镜像,不在请求中直连工作平台库。
|
||||
'jiansun.dbo.ekf_ReturnPickupManagement' => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
'object' => 'ekf_ReturnPickupManagement',
|
||||
'target' => 'js_ekf_return_pickup_management',
|
||||
'keys' => ['Id'],
|
||||
'watermark' => 'updateTime',
|
||||
'columns' => [
|
||||
'Id', 'orderId', 'urgencyLevel', 'expectedPickupTime', 'orderStatus', 'returnExpressNo',
|
||||
'paymentMethod', 'pickupType', 'shopName', 'transactionNo', 'senderName', 'senderPhone',
|
||||
'province', 'city', 'district', 'street', 'address', 'note', 'issueType', 'issueDescription',
|
||||
'expressFollowStatus', 'expressFollowResult', 'expressFollowDescription',
|
||||
'expressOrderTime', 'expressPickupTime', 'creator', 'createTime', 'updateTime', 'guid',
|
||||
'totalReturnQuantity', 'express_company', 'extend', 'canceler', 'sourceFrom',
|
||||
],
|
||||
'indexes' => [
|
||||
['orderId', 'Id'],
|
||||
['createTime', 'Id'],
|
||||
['updateTime', 'Id'],
|
||||
['express_company', 'createTime', 'Id'],
|
||||
['transactionNo', 'Id'],
|
||||
],
|
||||
],
|
||||
'jiansun.dbo.ekf_ReturnPickupManagement_Item' => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
'object' => 'ekf_ReturnPickupManagement_Item',
|
||||
'target' => 'js_ekf_return_pickup_management_item',
|
||||
'keys' => ['id'],
|
||||
'watermark' => 'updatedTime',
|
||||
'columns' => [
|
||||
'id', 'mainTableId', 'returnProcode', 'returnQuantity', 'barcode', 'platformProductId',
|
||||
'platformSkuId', 'warehouse', 'receiverName', 'receiverPhone', 'receiverAddress',
|
||||
'actualReturnOrderNo', 'actualReturnQuantity', 'unverifiedQuantity', 'returnProgress',
|
||||
'productName', 'length', 'width', 'height', 'volume', 'grossWeight', 'estimatedFreight',
|
||||
'updatedTime', 'actualReturnProcode',
|
||||
],
|
||||
'indexes' => [
|
||||
['mainTableId', 'id'],
|
||||
['barcode', 'id'],
|
||||
['returnProcode', 'id'],
|
||||
['updatedTime', 'id'],
|
||||
],
|
||||
],
|
||||
'jiansun.dbo.ekf_ReturnPickupManagement_TruthReExpressNumber' => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
'object' => 'ekf_ReturnPickupManagement_TruthReExpressNumber',
|
||||
'target' => 'js_ekf_return_pickup_truth_express',
|
||||
'keys' => ['Id'],
|
||||
'watermark' => 'createTime',
|
||||
'columns' => ['Id', 'mId', 'truthExpressNumber', 'createTime'],
|
||||
'indexes' => [
|
||||
['mId', 'Id'],
|
||||
['truthExpressNumber', 'Id'],
|
||||
['createTime', 'Id'],
|
||||
],
|
||||
],
|
||||
// 工作平台仓库资料:退货取件明细的「发货仓」同时存在工作平台短码
|
||||
// (wp_warehouse.code / wp_warehouseConfig.whCode,如 2852)和聚水潭
|
||||
// wms_co_id(如 15038426)。短码名称只从工作平台仓库表解析;这两张表
|
||||
// 都很小,页面与导出经共享镜像读取,不直连上游。
|
||||
'jiansun.dbo.wp_warehouse' => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
'object' => 'wp_warehouse',
|
||||
'target' => 'js_wp_warehouse',
|
||||
'keys' => ['id'],
|
||||
'watermark' => 'modified',
|
||||
'columns' => ['id', 'code', 'name', 'status', 'modified'],
|
||||
'indexes' => [
|
||||
['code', 'id'],
|
||||
],
|
||||
],
|
||||
// wp_warehouseConfig 没有更新时间列,按主键追加并依靠周期全量校准。
|
||||
'jiansun.dbo.wp_warehouseConfig' => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
'object' => 'wp_warehouseConfig',
|
||||
'target' => 'js_wp_warehouse_config',
|
||||
'keys' => ['Id'],
|
||||
'columns' => ['Id', 'moduleId', 'whCode', 'whName'],
|
||||
'indexes' => [
|
||||
['whCode', 'Id'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@ -33,4 +33,20 @@ return [
|
||||
realpath(storage_path('framework/views'))
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Compiled View Cache
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Blade still needs a writable path to generate PHP for each request, but
|
||||
| local development must not trust an existing compiled template. Setting
|
||||
| the compiler cache flag to false makes Laravel recompile every view and
|
||||
| leaves production's normal compiled-view behavior unchanged.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'cache' => env('APP_ENV', 'production') === 'local'
|
||||
? false
|
||||
: env('VIEW_CACHE', true),
|
||||
|
||||
];
|
||||
|
||||
@ -27,6 +27,7 @@ return new class extends Migration
|
||||
$table->string('source_object', 191);
|
||||
$table->string('status', 24)->default('pending');
|
||||
$table->string('run_mode', 24)->nullable();
|
||||
$table->unsignedTinyInteger('projection_version')->default(2);
|
||||
$table->uuid('generation')->nullable();
|
||||
$table->dateTime('window_started_at', 6)->nullable();
|
||||
$table->dateTime('window_ends_at', 6)->nullable();
|
||||
@ -47,11 +48,15 @@ return new class extends Migration
|
||||
$schema->create('product_purchase_price_mirror_rows', function (Blueprint $table): void {
|
||||
$table->unsignedBigInteger('item_id')->primary();
|
||||
$table->unsignedBigInteger('purchase_order_id')->index();
|
||||
$table->unsignedBigInteger('source_order_no')->nullable()->index();
|
||||
$table->string('supplier_name', 255)->nullable();
|
||||
$table->string('sku_id', 255)->nullable()->index();
|
||||
$table->string('style_id', 255)->nullable()->index();
|
||||
$table->decimal('quantity', 20, 4)->nullable();
|
||||
$table->decimal('unit_price', 20, 4)->nullable();
|
||||
$table->string('document_status', 50)->nullable()->index();
|
||||
$table->text('labels')->nullable();
|
||||
$table->dateTime('document_created_at', 6)->nullable()->index();
|
||||
$table->dateTime('finished_at', 6)->nullable()->index();
|
||||
$table->dateTime('purchase_updated_at', 6)->index();
|
||||
$table->uuid('_mirror_generation')->nullable()->index();
|
||||
@ -63,6 +68,10 @@ return new class extends Migration
|
||||
['document_status', 'sku_id', 'finished_at'],
|
||||
'product_purchase_price_latest_lookup',
|
||||
);
|
||||
$table->index(
|
||||
['document_status', 'document_created_at', 'item_id'],
|
||||
'product_purchase_online_daifa_lookup',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ return new class extends Migration
|
||||
if (! $schema->hasTable('third_party_warehouse_sync_states')) {
|
||||
$schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key', 100)->primary();
|
||||
$table->unsignedTinyInteger('scope_version')->default(1);
|
||||
$table->string('status', 32)->default('pending');
|
||||
$table->string('run_mode', 24)->nullable();
|
||||
$table->string('sync_phase', 24)->nullable();
|
||||
@ -78,6 +79,7 @@ return new class extends Migration
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('io_id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->unsignedBigInteger('wh_id')->default(0);
|
||||
$table->string('status', 32)->nullable();
|
||||
$table->string('io_type', 64)->nullable();
|
||||
$table->dateTime('occurred_at', 3)->nullable();
|
||||
@ -90,6 +92,10 @@ return new class extends Migration
|
||||
['generation', 'status', 'occurred_at', 'io_type'],
|
||||
'tpw_headers_report_index',
|
||||
);
|
||||
$table->index(
|
||||
['generation', 'wms_co_id', 'occurred_at', 'io_type'],
|
||||
'tpw_headers_warehouse_report_index',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (app()->environment('testing')
|
||||
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema = Schema::connection($this->connection());
|
||||
|
||||
if (! $schema->hasTable('product_price_overrides')) {
|
||||
$schema->create('product_price_overrides', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedInteger('company_id');
|
||||
$table->char('upstream_key', 64);
|
||||
$table->string('barcode', 191);
|
||||
$table->string('spu', 191)->nullable();
|
||||
$table->text('product_name')->nullable();
|
||||
$table->string('category', 255)->nullable();
|
||||
$table->string('production_status', 120)->nullable();
|
||||
$table->decimal('minimum_price', 20, 4)->nullable();
|
||||
$table->decimal('price_limit', 20, 4)->nullable();
|
||||
$table->decimal('purchase_price', 20, 4)->nullable();
|
||||
$table->decimal('shipping_fee', 20, 4)->nullable();
|
||||
$table->decimal('jingxi_limit_price', 20, 4)->nullable();
|
||||
$table->string('source', 24);
|
||||
$table->unsignedInteger('created_by')->nullable();
|
||||
$table->unsignedInteger('updated_by')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['company_id', 'upstream_key'], 'product_price_overrides_company_key_unique');
|
||||
$table->index(['company_id', 'barcode'], 'product_price_overrides_company_barcode');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('product_price_override_events')) {
|
||||
$schema->create('product_price_override_events', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedInteger('company_id');
|
||||
$table->char('upstream_key', 64);
|
||||
$table->string('source', 24);
|
||||
$table->json('before_values')->nullable();
|
||||
$table->json('after_values');
|
||||
$table->unsignedInteger('user_id')->nullable();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(
|
||||
['company_id', 'upstream_key', 'created_at'],
|
||||
'product_price_override_events_lookup'
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (app()->environment('testing')
|
||||
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema = Schema::connection($this->connection());
|
||||
$schema->dropIfExists('product_price_override_events');
|
||||
$schema->dropIfExists('product_price_overrides');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection(config('database.supplier_payable_maintenance_connection'));
|
||||
|
||||
if (! $schema->hasTable('supplier_payable_overrides')) {
|
||||
$schema->create('supplier_payable_overrides', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedInteger('company_id');
|
||||
$table->string('dataset', 64);
|
||||
$table->string('record_key', 191);
|
||||
$table->json('values');
|
||||
$table->unsignedInteger('created_by')->nullable();
|
||||
$table->unsignedInteger('updated_by')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['company_id', 'dataset', 'record_key'], 'supplier_payable_override_unique');
|
||||
$table->index(['company_id', 'dataset'], 'supplier_payable_override_dataset');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('supplier_payable_override_events')) {
|
||||
$schema->create('supplier_payable_override_events', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedInteger('company_id');
|
||||
$table->string('dataset', 64);
|
||||
$table->string('record_key', 191);
|
||||
$table->string('source', 24);
|
||||
$table->json('before_values')->nullable();
|
||||
$table->json('after_values');
|
||||
$table->unsignedInteger('user_id')->nullable();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->index(['company_id', 'dataset', 'record_key'], 'supplier_payable_override_event_lookup');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection(config('database.supplier_payable_maintenance_connection'));
|
||||
$schema->dropIfExists('supplier_payable_override_events');
|
||||
$schema->dropIfExists('supplier_payable_overrides');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
|
||||
if (! $schema->hasTable('jd_daily_bill_periods')) {
|
||||
$schema->create('jd_daily_bill_periods', function (Blueprint $t): void {
|
||||
$t->date('period_start')->primary();
|
||||
$t->date('period_end');
|
||||
$t->uuid('active_generation');
|
||||
$t->string('source_filename');
|
||||
$t->char('source_sha256', 64);
|
||||
$t->unsignedInteger('row_count');
|
||||
$t->decimal('income', 20, 2);
|
||||
$t->decimal('expense', 20, 2);
|
||||
$t->dateTime('imported_at');
|
||||
});
|
||||
}
|
||||
if (! $schema->hasTable('jd_daily_bill_rows')) {
|
||||
$schema->create('jd_daily_bill_rows', function (Blueprint $t): void {
|
||||
$t->uuid('generation');
|
||||
$t->string('record_code', 64);
|
||||
$t->string('store_name');
|
||||
$t->string('bill_item');
|
||||
$t->string('order_no', 64)->nullable();
|
||||
$t->dateTime('occurred_at');
|
||||
$t->decimal('income', 20, 2);
|
||||
$t->decimal('expense', 20, 2);
|
||||
$t->json('row_data');
|
||||
$t->primary(['generation', 'record_code'], 'jd_daily_pk');
|
||||
$t->index(['generation', 'occurred_at', 'record_code'], 'jd_daily_time');
|
||||
$t->index(['generation', 'occurred_at', 'income', 'expense'], 'jd_daily_summary');
|
||||
$t->index(['generation', 'store_name', 'occurred_at'], 'jd_daily_shop');
|
||||
$t->index(['generation', 'bill_item', 'occurred_at'], 'jd_daily_item');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$schema->dropIfExists('jd_daily_bill_rows');
|
||||
$schema->dropIfExists('jd_daily_bill_periods');
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user