diff --git a/.env.jushuitan.example b/.env.jushuitan.example new file mode 100644 index 00000000..3d483848 --- /dev/null +++ b/.env.jushuitan.example @@ -0,0 +1,30 @@ +# ============================================================ +# 聚水潭(聚石塔 / 奇门)开放平台接入 —— caiwu 直连发货数据 +# 复用工作平台现有授权。把下列键补进 runtime/.env(勿提交 Git)。 +# 值留空时同步器会安全拒绝请求,不会误拉。 +# ============================================================ + +# 奇门网关地址(对应工作平台配置 ErpJSTUrl) +JST_QIMEN_URL= + +# 授权凭证(复用现有一套) +JST_APP_KEY= +JST_APP_SECRET= +JST_CUSTOMER_ID= + +# 奇门固定参数(一般无需改) +JST_API_VERSION=2 +JST_FORMAT=json +JST_SIMPLIFY=true + +# 同步参数(可选,均有默认值) +JST_SYNC_PAGE_LIMIT=100 +JST_SYNC_OVERLAP_HOURS=48 +JST_SYNC_WINDOW_HOURS=24 +JST_SYNC_MAX_PAGES=0 +JST_REQUEST_TIMEOUT=30 +JST_RETRY_TIMES=5 +JST_RETRY_SLEEP_MS=1000 + +# 定时增量总开关:凭证填好、迁移跑完、联调校准后再设为 true +JST_SYNC_ENABLED=false diff --git a/app/Business/CourierSurchargeQuoteRepository.php b/app/Business/CourierSurchargeQuoteRepository.php index e5512285..8f43052b 100644 --- a/app/Business/CourierSurchargeQuoteRepository.php +++ b/app/Business/CourierSurchargeQuoteRepository.php @@ -156,6 +156,43 @@ class CourierSurchargeQuoteRepository return $this->types->activeNames(); } + /** @param array $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']); + + 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 $data */ + public function importIdentity(array $data): string + { + return hash('sha256', json_encode([ + trim((string) ($data['courier'] ?? '')), + trim((string) ($data['scope'] ?? '')), + trim((string) ($data['reconciler'] ?? '')), + $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 $data */ public function create(array $data, int $userId): CourierSurchargeQuote { @@ -275,6 +312,18 @@ class CourierSurchargeQuoteRepository ]; } + /** @param array $types @return list */ + 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 */ private function present(CourierSurchargeQuote $quote): array { diff --git a/app/Business/CourierSurchargeQuoteWorkbookImporter.php b/app/Business/CourierSurchargeQuoteWorkbookImporter.php new file mode 100644 index 00000000..22ecff3a --- /dev/null +++ b/app/Business/CourierSurchargeQuoteWorkbookImporter.php @@ -0,0 +1,382 @@ +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, 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:B9')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER)->setWrapText(true); + foreach (range(1, 9) 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,display:array,errors:list,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' => '表头不正确。请下载最新模板,并保持 10 个字段名称及顺序不变。', + ]); + } + + $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,errors:list}> $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,display:array,errors:list,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 (array_slice(self::HEADERS, 0, 9) as $header) { + if ($display[$header] === '') { + $errors[] = $header . '不能为空。'; + } + } + + $this->checkLength($display, '快递名称', 191, $errors); + $this->checkLength($display, '对账人', 100, $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['对账人'], + '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 $values @param list $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 ?? ''))); + } +} diff --git a/app/Console/Commands/ImportDewuSettlementBills.php b/app/Console/Commands/ImportDewuSettlementBills.php new file mode 100644 index 00000000..9816c4a3 --- /dev/null +++ b/app/Console/Commands/ImportDewuSettlementBills.php @@ -0,0 +1,72 @@ +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; + } +} diff --git a/app/Console/Commands/ImportDouyinAuthoritativeBills.php b/app/Console/Commands/ImportDouyinAuthoritativeBills.php index e5a327ce..c096c2b2 100644 --- a/app/Console/Commands/ImportDouyinAuthoritativeBills.php +++ b/app/Console/Commands/ImportDouyinAuthoritativeBills.php @@ -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; } } diff --git a/app/Console/Commands/ImportJdDailyBills.php b/app/Console/Commands/ImportJdDailyBills.php index 2433acef..3b0ccb3c 100644 --- a/app/Console/Commands/ImportJdDailyBills.php +++ b/app/Console/Commands/ImportJdDailyBills.php @@ -10,8 +10,8 @@ use Throwable; class ImportJdDailyBills extends Command { protected $signature = 'business-mirror:import-jd-daily - {path : 胜算京东日账单完整月份 UTF-8 CSV} {month : YYYY-MM} - {--count= : 核对笔数} {--income= : 核对收入} {--expense= : 核对支出}'; + {path : 胜算京东日账单指定月份月初至截止日期 UTF-8 CSV} {month : YYYY-MM} + {--through= : 截止日期 YYYY-MM-DD,默认月末} {--count= : 核对笔数} {--income= : 核对收入} {--expense= : 核对支出}'; protected $description = '核对并启用胜算京东日账单月份镜像,保留旧版本'; @@ -24,7 +24,7 @@ class ImportJdDailyBills extends Command } 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')); $this->info('京东日账单已启用,笔数:' . $result['row_count']); return self::SUCCESS; } catch (Throwable $e) { diff --git a/app/Console/Commands/ImportPinduoduoSettlementBills.php b/app/Console/Commands/ImportPinduoduoSettlementBills.php new file mode 100644 index 00000000..fd32f2cf --- /dev/null +++ b/app/Console/Commands/ImportPinduoduoSettlementBills.php @@ -0,0 +1,72 @@ +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; + } +} diff --git a/app/Console/Commands/SyncProductPurchasePriceMirror.php b/app/Console/Commands/SyncProductPurchasePriceMirror.php index f657358b..ea766fe9 100644 --- a/app/Console/Commands/SyncProductPurchasePriceMirror.php +++ b/app/Console/Commands/SyncProductPurchasePriceMirror.php @@ -16,7 +16,7 @@ class SyncProductPurchasePriceMirror extends Command {--batch= : 每批最多读取行数} {--max-batches= : 本轮最多批次数,0 表示直到完成}'; - protected $description = '同步采购单管理的普通商品单价,并保留可断点的共享业务镜像'; + protected $description = '同步采购单管理明细和普通商品单价,并保留可断点的共享业务镜像'; public function handle( ProductPurchasePriceMirrorRepository $mirror, diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index d7558795..7ec03ff1 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -55,6 +55,15 @@ class Kernel extends ConsoleKernel ->runInBackground(); } + // 聚水潭发货数据增量同步:每 10 分钟按 modified 水位拉销售出库单。 + // 默认关闭(JST_SYNC_ENABLED=false);填好凭证并完成迁移后再打开。 + if ((bool) env('JST_SYNC_ENABLED', false)) { + $schedule->command('jushuitan:sync-shipping') + ->everyTenMinutes() + ->withoutOverlapping(30) + ->runInBackground(); + } + } /** diff --git a/app/Http/Controllers/Business/CourierAgreementSignatures.php b/app/Http/Controllers/Business/CourierAgreementSignatures.php index 13e908ce..60e336cf 100644 --- a/app/Http/Controllers/Business/CourierAgreementSignatures.php +++ b/app/Http/Controllers/Business/CourierAgreementSignatures.php @@ -56,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) { @@ -79,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'); @@ -91,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); @@ -282,7 +303,9 @@ class CourierAgreementSignatures extends Controller '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'], @@ -369,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'])), diff --git a/app/Http/Controllers/Business/CourierSurchargeQuotes.php b/app/Http/Controllers/Business/CourierSurchargeQuotes.php index 43893dc5..5825c7f6 100644 --- a/app/Http/Controllers/Business/CourierSurchargeQuotes.php +++ b/app/Http/Controllers/Business/CourierSurchargeQuotes.php @@ -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 */ private function validated(Request $request): array { diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 45068300..d8e77296 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -35,6 +35,7 @@ use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; use Modules\EcommerceFinance\View\BillOffsetPageData; use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository; use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository; +use Modules\EcommerceFinance\Repositories\ShippingOrderRepository; use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; use Modules\EcommerceFinance\Repositories\InventoryRepository; @@ -53,6 +54,8 @@ use Modules\EcommerceFinance\Repositories\SmallPaymentWorkOrderMirrorRepository; use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository; use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository; use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory; +use Modules\EcommerceFinance\Services\SupplierPayableMaintenanceRegistry; +use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore; use Modules\EcommerceFinance\View\PlatformPromotionAccountPageData; use Modules\EcommerceFinance\View\OrderDataPageData; use RuntimeException; @@ -94,11 +97,14 @@ class Pages extends Controller private readonly DingPayableWorkflowMirrorRepository $dingPayableWorkflows, private readonly DingOnlineDaifaPriceMirrorRepository $onlineDaifaPrices, private readonly SupplierPayableReportRepository $supplierPayableReports, + private readonly SupplierPayableOverrideStore $supplierPayableOverrides, + private readonly SupplierPayableMaintenanceRegistry $supplierPayableMaintenance, private readonly UpstreamQueryCacheFactory $upstreamCaches, private readonly CourierSurchargeQuoteRepository $courierSurchargeQuotes, private readonly CourierSurchargeTypeRepository $courierSurchargeTypes, private readonly CourierSurchargeAuditLogRepository $courierSurchargeAudits, private readonly JushuitanShopBalanceRepository $jushuitanShopBalances, + private readonly ShippingOrderRepository $shippingOrders, ) { $this->middleware('auth'); $this->middleware('company.identify'); @@ -623,9 +629,38 @@ class Pages extends Controller } try { - return CourierAgreementSignature::query() + $type = $this->agreementFilterType(); + if ($type === 'quote-confirmation') { + return collect(); + } + + $query = CourierAgreementSignature::query(); + $search = $this->agreementFilterSearch(); + $status = $this->agreementFilterStatus(); + + if ($search !== '') { + $like = '%' . addcslashes($search, '%_\\') . '%'; + $query->where(function ($builder) use ($like): void { + $builder + ->where('business_no', 'like', $like) + ->orWhere('courier_name', 'like', $like) + ->orWhere('organization_name', 'like', $like) + ->orWhere('operator_name', 'like', $like) + ->orWhere('company_full_name', 'like', $like); + }); + } + + if ($status !== null) { + $query->where('status', $status); + } + + if ($type !== null) { + $query->where('document_type', $type); + } + + return $query ->latest('id') - ->paginate(50, ['*'], 'agreement_page')->withQueryString(); + ->paginate(50, ['*'], 'agreement_task_page')->withQueryString(); } catch (QueryException $exception) { // 迁移尚未执行时仍允许打开页面;发送接口会明确提示数据库未就绪。 report($exception); @@ -633,6 +668,38 @@ class Pages extends Controller } } + protected function agreementFilterSearch(): string + { + return mb_substr(trim((string) request('agreement_search', '')), 0, 191); + } + + protected function agreementFilterStatus(): ?string + { + $status = trim((string) request('agreement_status', '')); + $allowed = [ + CourierAgreementSignature::STATUS_PREPARED, + CourierAgreementSignature::STATUS_SENDING, + CourierAgreementSignature::STATUS_SIGNING, + CourierAgreementSignature::STATUS_COMPLETED, + CourierAgreementSignature::STATUS_ARCHIVED, + CourierAgreementSignature::STATUS_REVOKED, + CourierAgreementSignature::STATUS_EXPIRED, + CourierAgreementSignature::STATUS_REJECTED, + CourierAgreementSignature::STATUS_FAILED, + CourierAgreementSignature::STATUS_UNKNOWN, + ]; + + return in_array($status, $allowed, true) ? $status : null; + } + + protected function agreementFilterType(): ?string + { + $type = trim((string) request('agreement_type', '')); + $allowed = array_keys(CourierAgreementSignature::DOCUMENTS); + + return in_array($type, $allowed, true) ? $type : null; + } + protected function agreementSubjects(?string $page): Collection { if ($page !== 'agreement-signing') { @@ -658,10 +725,36 @@ class Pages extends Controller } try { - return CourierQuoteSignature::query() - ->with('version') + $type = $this->agreementFilterType(); + if ($type !== null && $type !== 'quote-confirmation') { + return collect(); + } + + $query = CourierQuoteSignature::query()->with('version'); + $search = $this->agreementFilterSearch(); + $status = $this->agreementFilterStatus(); + + if ($search !== '') { + $like = '%' . addcslashes($search, '%_\\') . '%'; + $query->where(function ($builder) use ($like): void { + $builder + ->where('organization_name', 'like', $like) + ->orWhere('operator_name', 'like', $like) + ->orWhereHas('version', function ($versionQuery) use ($like): void { + $versionQuery + ->where('version_code', 'like', $like) + ->orWhere('carrier_name', 'like', $like); + }); + }); + } + + if ($status !== null) { + $query->where('status', $status); + } + + return $query ->latest('id') - ->paginate(50, ['*'], 'agreement_page')->withQueryString(); + ->paginate(50, ['*'], 'agreement_quote_page')->withQueryString(); } catch (QueryException $exception) { // 报价签署迁移尚未执行时仍允许打开协议签署页。 report($exception); @@ -709,6 +802,7 @@ class Pages extends Controller 'online-daifa-price' => $this->contentForOnlineDaifaPrice(), 'courier-surcharge' => $this->contentForCourierSurcharge(), 'freight-difference', 'other-surcharge-summary', 'return-fee' => $this->contentForCourierPayableSummary($page), + 'shipping-data' => $this->contentForShippingData(), 'withdrawal-management' => $this->contentForWithdrawalManagement(), 'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(), default => null, @@ -872,6 +966,158 @@ class Pages extends Controller * 报价维护与后续附加费结算分开:本页只负责渠道、类型、结算方式、 * 计价标准和生效时间的维护展示,结算页将来根据这些规则读取业务镜像。 */ + protected function contentForShippingData(): array + { + $request = request(); + $filters = $this->shippingDataFilters($request); + $page = max((int) $request->query('page', 1), 1); + $limit = min(max((int) setting('default.list_limit', 25), 1), 100); + $rows = new LengthAwarePaginator([], 0, $limit, $page, [ + 'path' => $request->url(), + 'query' => $request->except('page'), + ]); + $sync = ['code' => 'unavailable', 'last_succeeded_at' => null]; + $error = null; + + try { + $result = $this->shippingOrders->paginate($filters, $page, $limit); + $rows = new LengthAwarePaginator( + Collection::make($result['data'] ?? []), + (int) ($result['total'] ?? 0), + $limit, + $page, + ['path' => $request->url(), 'query' => $request->except('page')], + ); + $sync = $this->shippingOrders->syncStatus(); + } catch (Throwable $exception) { + report($exception); + $error = '发货数据暂时无法读取(可能尚未运行迁移或同步)。'; + } + + return [ + 'view' => 'business.pages.courier-payables.shipping-data', + 'shippingRows' => $rows, + 'shippingFilters' => $filters, + 'shippingSync' => $sync, + 'shippingError' => $error, + 'shippingExportUrl' => route('business.courier-payables.shipping-data.export'), + ]; + } + + public function exportShippingData(Request $request): StreamedResponse + { + $filters = $this->shippingDataFilters($request); + $columns = $this->shippingDataColumns(); + + return response()->streamDownload(function () use ($filters, $columns): void { + $csv = fopen('php://output', 'w'); + if ($csv === false) { + throw new \RuntimeException('无法创建发货数据导出流。'); + } + + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, array_column($columns, 'label')); + + foreach ($this->shippingOrders->exportRows($filters) as $row) { + fputcsv($csv, array_map(function (array $column) use ($row): mixed { + $value = data_get($row, $column['field']); + + return match ($column['type']) { + 'identifier' => $this->csvIdentifier($value), + 'number' => $value === null || $value === '' ? '' : $value, + default => $this->csvText($value), + }; + }, $columns)); + } + + fclose($csv); + }, '发货数据_' . now()->format('Ymd_His') . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + + /** @return array */ + private function shippingDataFilters(Request $request): array + { + $text = static fn (string $key, int $length = 128): string => mb_substr( + trim((string) $request->query($key, '')), + 0, + $length, + ); + $date = static function (mixed $value): string { + $value = trim((string) $value); + + return preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $value) === 1 ? $value : ''; + }; + + return [ + 'express_no' => $text('express_no', 80), + 'sourceno' => $text('sourceno', 80), + 'barcode' => $text('barcode'), + 'supplier_name' => $text('supplier_name'), + 'shop_name' => $text('shop_name', 255), + 'warehouse_name' => $text('warehouse_name'), + 'express_company' => $text('express_company'), + 'ship_from' => $date($request->query('ship_from', '')), + 'ship_to' => $date($request->query('ship_to', '')), + ]; + } + + /** @return list */ + private function shippingDataColumns(): array + { + return [ + ['field' => 'express_no', 'label' => '快递单号', 'type' => 'identifier'], + ['field' => 'trade_no', 'label' => '交易单号', 'type' => 'identifier'], + ['field' => 'item_id', 'label' => '商品ID', 'type' => 'identifier'], + ['field' => 'barcode', 'label' => '条形码', 'type' => 'identifier'], + ['field' => 'product_name', 'label' => '商品名称', 'type' => 'text'], + ['field' => 'qty', 'label' => '数量', 'type' => 'number'], + ['field' => 'receiver_state', 'label' => '省', 'type' => 'text'], + ['field' => 'buyer_nick', 'label' => '买家昵称', 'type' => 'text'], + ['field' => 'receiver_name', 'label' => '收货姓名', 'type' => 'text'], + ['field' => 'shop_name', 'label' => '店铺名称', 'type' => 'text'], + ['field' => 'express_name1', 'label' => '快递名称1', 'type' => 'text'], + ['field' => 'warehouse_name', 'label' => '仓库', 'type' => 'text'], + ['field' => 'ship_time', 'label' => '发货时间', 'type' => 'text'], + ['field' => 'freight', 'label' => '运费', 'type' => 'number'], + ['field' => 'online_weight', 'label' => '线上重量', 'type' => 'number'], + ['field' => 'actual_weight', 'label' => '实称重量', 'type' => 'number'], + ['field' => 'length_cm', 'label' => '长(cm)', 'type' => 'number'], + ['field' => 'width_cm', 'label' => '宽(cm)', 'type' => 'number'], + ['field' => 'height_cm', 'label' => '高(cm)', 'type' => 'number'], + ['field' => 'volume_cm3', 'label' => '体积(cm³)', 'type' => 'number'], + ['field' => 'receiver_city', 'label' => '市', 'type' => 'text'], + ['field' => 'receiver_district', 'label' => '区', 'type' => 'text'], + ['field' => 'supplier_name', 'label' => '供应商', 'type' => 'text'], + ['field' => 'receiver_address', 'label' => '收货详细地址', 'type' => 'text'], + ['field' => 'receiver_mobile', 'label' => '手机号码', 'type' => 'identifier'], + ['field' => 'receiver_phone', 'label' => '座机号码', 'type' => 'identifier'], + ['field' => 'express_name2', 'label' => '快递名称2', 'type' => 'text'], + ['field' => 'platform', 'label' => '平台', 'type' => 'text'], + ['field' => 'buyer_memo', 'label' => '买家留言', 'type' => 'text'], + ['field' => 'seller_memo', 'label' => '卖家备注', 'type' => 'text'], + ['field' => 'package_no', 'label' => '配货单号', 'type' => 'identifier'], + ['field' => 'amount', 'label' => '金额', 'type' => 'number'], + ['field' => 'product_code', 'label' => '货号', 'type' => 'identifier'], + ['field' => 'download_time', 'label' => '下载时间', 'type' => 'text'], + ['field' => 'pay_time', 'label' => '付款时间', 'type' => 'text'], + ['field' => 'freight_rate', 'label' => '费率', 'type' => 'number'], + ['field' => 'freight_formula', 'label' => '公式', 'type' => 'text'], + ['field' => 'weight_kg', 'label' => '重量(kg)', 'type' => 'number'], + ['field' => 'settle_weight_kg', 'label' => '结算重量(kg)', 'type' => 'number'], + ['field' => 'settle_volume_cm3', 'label' => '结算体积(cm³)', 'type' => 'number'], + ['field' => 'recon_weight_kg', 'label' => '对账重量(kg)', 'type' => 'number'], + ['field' => 'recon_volume_cm3', 'label' => '对账体积(cm³)', 'type' => 'number'], + ['field' => 'finished_barcode', 'label' => '成品条码', 'type' => 'identifier'], + ['field' => 'finished_name', 'label' => '成品名称', 'type' => 'text'], + ['field' => 'finished_supplier', 'label' => '成品供应商', 'type' => 'text'], + ['field' => 'settle_freight', 'label' => '结算运费', 'type' => 'number'], + ['field' => 'front_no', 'label' => '换单前单号', 'type' => 'identifier'], + ]; + } + protected function contentForCourierSurcharge(): array { $clean = static fn (mixed $value, int $length = 60): string => mb_substr(trim((string) $value), 0, $length); @@ -1267,6 +1513,7 @@ class Pages extends Controller 'onlineDaifaStatus' => $result['status'] ?? 'unknown', 'onlineDaifaSyncedAt' => $result['last_synced_at'] ?? null, 'onlineDaifaSummary' => $result['summary'] ?? [], + 'onlineDaifaSources' => $result['source_statuses'] ?? [], 'onlineDaifaError' => null, ]; } catch (Throwable $exception) { @@ -1281,6 +1528,7 @@ class Pages extends Controller 'onlineDaifaStatus' => 'unavailable', 'onlineDaifaSyncedAt' => null, 'onlineDaifaSummary' => [], + 'onlineDaifaSources' => [], 'onlineDaifaError' => '线上代发价格镜像尚未完成同步,请稍后重试。', ]; } @@ -1311,9 +1559,11 @@ class Pages extends Controller $originator = trim((string) ($workflow['originatorUserName'] ?? '')) ?: trim((string) ($workflow['originatorUserId'] ?? '')); $matchedProduct = (array) ($row['matched_product'] ?? []); - $productName = ($matchedProduct['matched'] ?? false) - ? (string) ($matchedProduct['name'] ?? '') - : '(未匹配)'; + $productName = ($row['source_type'] ?? 'ding') === 'purchase' + ? '' + : (($matchedProduct['matched'] ?? false) + ? (string) ($matchedProduct['name'] ?? '') + : '(未匹配)'); fputcsv($csv, [ $this->csvIdentifier($approvalNumber), @@ -2323,10 +2573,16 @@ class Pages extends Controller ['filters' => $filters, 'page' => $page, 'limit' => $limit], fn () => $this->supplierPayableReports->boardDeductions($filters, $page, $limit), ); + $rows = $this->supplierPayableOverrides->decorate( + 'board-deductions', + (array) ($result['rows'] ?? []), + (int) (company_id() ?: 1), + ); return [ - 'boardDeductions' => $this->supplierPayablePaginator((array) ($result['rows'] ?? []), $limit, $page), + 'boardDeductions' => $this->supplierPayablePaginator($rows, $limit, $page), 'boardDeductionFilters' => $filters, + 'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'), 'boardDeductionStatus' => $cache->responseHeaders(), 'boardDeductionError' => null, ]; @@ -2334,6 +2590,7 @@ class Pages extends Controller return [ 'boardDeductions' => $this->supplierPayablePaginator([], $limit, $page), 'boardDeductionFilters' => $filters, + 'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'), 'boardDeductionStatus' => [], 'boardDeductionError' => $exception->getMessage(), ]; @@ -2343,6 +2600,7 @@ class Pages extends Controller return [ 'boardDeductions' => $this->supplierPayablePaginator([], $limit, $page), 'boardDeductionFilters' => $filters, + 'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'), 'boardDeductionStatus' => [], 'boardDeductionError' => '板材扣款数据暂时无法读取,请稍后重试。', ]; @@ -2368,10 +2626,16 @@ class Pages extends Controller ['filters' => $filters, 'page' => $page, 'limit' => $limit], fn () => $this->supplierPayableReports->supplierPayables($filters, $page, $limit), ); + $rows = $this->supplierPayableOverrides->decorate( + 'supplier-payables-report', + (array) ($result['rows'] ?? []), + (int) (company_id() ?: 1), + ); return [ - 'supplierPayables' => $this->supplierPayablePaginator((array) ($result['rows'] ?? []), $limit, $page), + 'supplierPayables' => $this->supplierPayablePaginator($rows, $limit, $page), 'supplierPayableFilters' => $filters, + 'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'), 'supplierPayableStatus' => $cache->responseHeaders(), 'supplierPayableError' => null, ]; @@ -2379,6 +2643,7 @@ class Pages extends Controller return [ 'supplierPayables' => $this->supplierPayablePaginator([], $limit, $page), 'supplierPayableFilters' => $filters, + 'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'), 'supplierPayableStatus' => [], 'supplierPayableError' => $exception->getMessage(), ]; @@ -2388,6 +2653,7 @@ class Pages extends Controller return [ 'supplierPayables' => $this->supplierPayablePaginator([], $limit, $page), 'supplierPayableFilters' => $filters, + 'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'), 'supplierPayableStatus' => [], 'supplierPayableError' => '供应商应付数据暂时无法读取,请稍后重试。', ]; diff --git a/app/Http/Controllers/Business/ProductPrices.php b/app/Http/Controllers/Business/ProductPrices.php index d506478d..44a90f55 100644 --- a/app/Http/Controllers/Business/ProductPrices.php +++ b/app/Http/Controllers/Business/ProductPrices.php @@ -8,6 +8,7 @@ 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; @@ -16,6 +17,7 @@ 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'); @@ -93,6 +95,11 @@ class ProductPrices extends Controller ); } + public function template(): StreamedResponse + { + return $this->template->download('商品价格', $this->importer->templateFields()); + } + public function update(Request $request, string $upstreamKey): RedirectResponse { $values = $request->validate( diff --git a/app/Http/Controllers/Business/SupplierPayableActions.php b/app/Http/Controllers/Business/SupplierPayableActions.php new file mode 100644 index 00000000..520315f8 --- /dev/null +++ b/app/Http/Controllers/Business/SupplierPayableActions.php @@ -0,0 +1,196 @@ +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>> */ + 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; + } +} diff --git a/app/Support/CourierAgreementSignatureService.php b/app/Support/CourierAgreementSignatureService.php index 49149f11..f10dfa51 100644 --- a/app/Support/CourierAgreementSignatureService.php +++ b/app/Support/CourierAgreementSignatureService.php @@ -285,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, ]]; } @@ -352,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) ?: '协议签署'; diff --git a/compose.jiangling.yml b/compose.jiangling.yml index d7ec45c0..31d22d45 100644 --- a/compose.jiangling.yml +++ b/compose.jiangling.yml @@ -95,6 +95,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 diff --git a/config/business_navigation.php b/config/business_navigation.php index e436ab95..5880e57c 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -372,9 +372,10 @@ $navigation = [ ['slug' => 'taobao-wechat', 'title' => '淘系微信账户'], ['slug' => 'pinduoduo', 'title' => '拼多多账户'], ['slug' => 'jd-wallet', 'title' => '京东网银钱包账户'], - ['slug' => 'jd-transactions', 'title' => '京东流水'], + ['slug' => 'jd-transactions', 'title' => '京东账单'], ['slug' => 'douyin', 'title' => '抖音账户'], ['slug' => 'dewu', 'title' => '得物账户'], + ['slug' => 'vipshop', 'title' => '唯品会账户'], ], ], [ @@ -531,6 +532,7 @@ $navigation = [ ['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' => '赔偿明细'], diff --git a/config/database.php b/config/database.php index 723b4175..36321b8e 100644 --- a/config/database.php +++ b/config/database.php @@ -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 @@ -153,6 +160,48 @@ return [ 'options' => $mysqlSslOptions(), ], + // 聚水潭发货数据专用落库连接。默认指向 docker-compose.local.yml 的本地 + // MySQL 容器(网络别名 mysql / 库 akaunting),与远程共享镜像库隔离, + // 便于先本地验证真实数据。上生产时用 env 覆盖到目标库即可。 + 'jushuitan_local' => [ + 'driver' => 'mysql', + 'host' => env('JST_DB_HOST', 'mysql'), + 'port' => env('JST_DB_PORT', '3306'), + 'database' => env('JST_DB_DATABASE', 'akaunting'), + 'username' => env('JST_DB_USERNAME', 'akaunting'), + 'password' => env('JST_DB_PASSWORD', 'akaunting123'), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + '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' => [ 'driver' => 'mysql', 'url' => env('DATABASE_URL'), diff --git a/config/jushuitan.php b/config/jushuitan.php new file mode 100644 index 00000000..afbd0882 --- /dev/null +++ b/config/jushuitan.php @@ -0,0 +1,46 @@ + env('JST_QIMEN_URL', ''), + + // 授权凭证(复用现有)。 + 'app_key' => env('JST_APP_KEY', ''), + 'app_secret' => env('JST_APP_SECRET', ''), + 'customer_id' => env('JST_CUSTOMER_ID', ''), + + // 奇门固定参数。 + 'format' => env('JST_FORMAT', 'json'), + 'version' => env('JST_API_VERSION', '2'), + 'simplify' => env('JST_SIMPLIFY', 'true'), + + // 落地存储连接:默认本地 MySQL 容器(jushuitan_local,见 config/database.php), + // 与远程共享镜像库隔离;上生产时用 env JST_DB_CONNECTION 覆盖。 + 'connection' => env('JST_DB_CONNECTION', 'jushuitan_local'), + + // 同步参数。 + 'sync' => [ + 'page_limit' => (int) env('JST_SYNC_PAGE_LIMIT', 100), // 每页条数(奇门 limit) + 'overlap_hours' => (int) env('JST_SYNC_OVERLAP_HOURS', 48), // 增量重叠窗口,补迟到更新 + 'window_hours' => (int) env('JST_SYNC_WINDOW_HOURS', 24), // 单次拉取时间窗口 + 'max_pages' => (int) env('JST_SYNC_MAX_PAGES', 0), // 单次最多翻页,0=不限 + 'request_timeout' => (int) env('JST_REQUEST_TIMEOUT', 30), // 秒 + 'retry_times' => (int) env('JST_RETRY_TIMES', 5), + 'retry_sleep_ms' => (int) env('JST_RETRY_SLEEP_MS', 1000), + ], + + // 接口方法名(对应工作平台 ErpTradeData 中的调用)。 + 'methods' => [ + 'sctrade_list' => 'erp.sctrade.query', // 出库单列表(按时间窗分页) + 'sctrade_detail' => 'erp.sradesku.query', // 单据完整明细(按 tradeId) + ], +]; diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index 2239c7f9..f893fd83 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -198,7 +198,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' => [ diff --git a/database/migrations/2026_08_21_120000_create_product_purchase_price_mirror_tables.php b/database/migrations/2026_08_21_120000_create_product_purchase_price_mirror_tables.php index ae3d3164..a00395f1 100644 --- a/database/migrations/2026_08_21_120000_create_product_purchase_price_mirror_tables.php +++ b/database/migrations/2026_08_21_120000_create_product_purchase_price_mirror_tables.php @@ -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', + ); }); } } diff --git a/database/migrations/2026_09_17_000000_create_supplier_payable_override_tables.php b/database/migrations/2026_09_17_000000_create_supplier_payable_override_tables.php new file mode 100644 index 00000000..22fc7ad5 --- /dev/null +++ b/database/migrations/2026_09_17_000000_create_supplier_payable_override_tables.php @@ -0,0 +1,50 @@ +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'); + } +}; diff --git a/database/migrations/2026_09_22_000000_create_pinduoduo_settlement_bill_tables.php b/database/migrations/2026_09_22_000000_create_pinduoduo_settlement_bill_tables.php new file mode 100644 index 00000000..3d1f0fb9 --- /dev/null +++ b/database/migrations/2026_09_22_000000_create_pinduoduo_settlement_bill_tables.php @@ -0,0 +1,110 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + + if (! $schema->hasTable('pdd_settlement_bill_imports')) { + $schema->create('pdd_settlement_bill_imports', function (Blueprint $table): void { + $table->uuid('generation')->primary(); + $table->string('source_filename'); + $table->char('source_sha256', 64)->unique(); + $table->date('period_start'); + $table->date('period_end'); + $table->string('status', 32)->default('running'); + $table->unsignedBigInteger('expected_row_count'); + $table->decimal('expected_income_total', 20, 2); + $table->decimal('expected_expense_total', 20, 2); + $table->unsignedBigInteger('row_count')->default(0); + $table->unsignedBigInteger('excluded_account_rows')->default(0); + $table->unsignedInteger('store_count')->default(0); + $table->unsignedInteger('bill_item_count')->default(0); + $table->json('account_type_counts')->nullable(); + $table->decimal('income_total', 20, 2)->default(0); + $table->decimal('expense_total', 20, 2)->default(0); + $table->decimal('net_total', 20, 2)->default(0); + $table->string('last_error_code', 120)->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + + $table->index(['status', 'period_start', 'period_end'], 'pdd_bill_import_period_idx'); + }); + } + + if (! $schema->hasTable('pdd_settlement_bill_staging')) { + $schema->create('pdd_settlement_bill_staging', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->uuid('generation'); + $this->billColumns($table); + $table->unique(['generation', 'record_code'], 'pdd_bill_stage_generation_record_uq'); + $table->index(['generation', 'occurred_at'], 'pdd_bill_stage_generation_time_idx'); + }); + } + + if (! $schema->hasTable('pdd_settlement_bill_rows')) { + $schema->create('pdd_settlement_bill_rows', function (Blueprint $table): void { + $table->string('record_code', 191)->primary(); + $this->billColumns($table, false); + $table->uuid('source_generation'); + $table->char('source_sha256', 64); + $table->timestamp('imported_at'); + + $table->index(['occurred_at', 'record_code'], 'pdd_bill_time_record_idx'); + $table->index(['store_name', 'occurred_at'], 'pdd_bill_store_time_idx'); + $table->index(['bill_item', 'occurred_at'], 'pdd_bill_item_time_idx'); + }); + } + } + + public function down(): void + { + if (app()->environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + $schema->dropIfExists('pdd_settlement_bill_rows'); + $schema->dropIfExists('pdd_settlement_bill_staging'); + $schema->dropIfExists('pdd_settlement_bill_imports'); + } + + private function billColumns(Blueprint $table, bool $includeRecordCode = true): void + { + if ($includeRecordCode) { + $table->string('record_code', 191); + } + $table->string('store_name')->nullable(); + $table->string('custom_remark')->nullable(); + $table->string('bill_item')->nullable(); + $table->string('merchant_order_no', 191)->nullable(); + $table->string('online_order_no', 191)->nullable(); + $table->string('business_type')->nullable(); + $table->string('business_description', 1000)->nullable(); + $table->string('account_name')->nullable(); + $table->dateTime('occurred_at'); + $table->decimal('income_amount', 20, 2)->default(0); + $table->decimal('expense_amount', 20, 2)->default(0); + $table->decimal('signed_amount', 20, 2)->default(0); + $table->string('remark', 1000)->nullable(); + $table->dateTime('source_updated_at')->nullable(); + $table->string('source_modified_by')->nullable(); + $table->unsignedBigInteger('source_row_number'); + } +}; diff --git a/database/migrations/2026_09_22_010000_create_dewu_settlement_bill_tables.php b/database/migrations/2026_09_22_010000_create_dewu_settlement_bill_tables.php new file mode 100644 index 00000000..adc15393 --- /dev/null +++ b/database/migrations/2026_09_22_010000_create_dewu_settlement_bill_tables.php @@ -0,0 +1,119 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + + if (! $schema->hasTable('dewu_settlement_bill_imports')) { + $schema->create('dewu_settlement_bill_imports', function (Blueprint $table): void { + $table->uuid('generation')->primary(); + $table->string('source_filename'); + $table->char('source_sha256', 64)->unique(); + $table->date('period_start'); + $table->date('period_end'); + $table->string('status', 32)->default('running'); + $table->unsignedBigInteger('expected_row_count'); + $table->decimal('expected_income_total', 20, 2); + $table->decimal('expected_expense_total', 20, 2); + $table->unsignedBigInteger('row_count')->default(0); + $table->unsignedBigInteger('excluded_rows')->default(0); + $table->unsignedInteger('store_count')->default(0); + $table->unsignedInteger('bill_item_count')->default(0); + $table->json('channel_counts')->nullable(); + $table->decimal('income_total', 20, 2)->default(0); + $table->decimal('expense_total', 20, 2)->default(0); + $table->decimal('net_total', 20, 2)->default(0); + $table->string('last_error_code', 120)->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + + $table->index(['status', 'period_start', 'period_end'], 'dewu_bill_import_period_idx'); + }); + } + + if (! $schema->hasTable('dewu_settlement_bill_staging')) { + $schema->create('dewu_settlement_bill_staging', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->uuid('generation'); + $this->billColumns($table); + $table->unique(['generation', 'record_code'], 'dewu_bill_stage_generation_record_uq'); + $table->index(['generation', 'bill_date'], 'dewu_bill_stage_generation_time_idx'); + }); + } + + if (! $schema->hasTable('dewu_settlement_bill_rows')) { + $schema->create('dewu_settlement_bill_rows', function (Blueprint $table): void { + $table->string('record_code', 191)->primary(); + $this->billColumns($table, false); + $table->uuid('source_generation'); + $table->char('source_sha256', 64); + $table->timestamp('imported_at'); + + $table->index(['bill_date', 'record_code'], 'dewu_bill_time_record_idx'); + $table->index(['store_name', 'bill_date'], 'dewu_bill_store_time_idx'); + $table->index(['bill_item', 'bill_date'], 'dewu_bill_item_time_idx'); + }); + } + } + + public function down(): void + { + if (app()->environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + $schema->dropIfExists('dewu_settlement_bill_rows'); + $schema->dropIfExists('dewu_settlement_bill_staging'); + $schema->dropIfExists('dewu_settlement_bill_imports'); + } + + private function billColumns(Blueprint $table, bool $includeRecordCode = true): void + { + if ($includeRecordCode) { + $table->string('record_code', 191); + } + $table->string('order_no', 191)->nullable(); + $table->string('online_order_no', 191)->nullable(); + $table->string('custom_remark')->nullable(); + $table->string('bill_item')->nullable(); + $table->string('store_name')->nullable(); + $table->string('order_type')->nullable(); + $table->string('goods_name', 1000)->nullable(); + $table->string('goods_no', 191)->nullable(); + $table->string('quantity', 64)->nullable(); + $table->string('channel')->nullable(); + $table->string('business_type')->nullable(); + $table->decimal('income_amount', 20, 2)->default(0); + $table->decimal('expense_amount', 20, 2)->default(0); + $table->decimal('signed_amount', 20, 2)->default(0); + $table->string('online_real_store')->nullable(); + $table->dateTime('order_created_at')->nullable(); + $table->dateTime('paid_refunded_at')->nullable(); + $table->dateTime('ship_return_at')->nullable(); + $table->dateTime('bill_date'); + $table->string('settle_status')->nullable(); + $table->string('pay_period')->nullable(); + $table->dateTime('pulled_at')->nullable(); + $table->dateTime('source_updated_at')->nullable(); + $table->string('source_modified_by')->nullable(); + $table->unsignedBigInteger('source_row_number'); + } +}; diff --git a/database/migrations/2026_09_22_100000_extend_product_purchase_price_mirror_for_online_daifa.php b/database/migrations/2026_09_22_100000_extend_product_purchase_price_mirror_for_online_daifa.php new file mode 100644 index 00000000..306c95f2 --- /dev/null +++ b/database/migrations/2026_09_22_100000_extend_product_purchase_price_mirror_for_online_daifa.php @@ -0,0 +1,94 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + + if ($schema->hasTable('product_purchase_price_sync_states') + && ! $schema->hasColumn('product_purchase_price_sync_states', 'projection_version')) { + $schema->table('product_purchase_price_sync_states', function (Blueprint $table): void { + // Existing rows use projection 1 and must complete a fresh full pass + // before purchase-management rows are exposed on the page. + $table->unsignedTinyInteger('projection_version')->default(1)->after('run_mode'); + }); + } + + if (! $schema->hasTable('product_purchase_price_mirror_rows')) { + return; + } + + $missing = array_filter([ + 'source_order_no' => ! $schema->hasColumn('product_purchase_price_mirror_rows', 'source_order_no'), + 'supplier_name' => ! $schema->hasColumn('product_purchase_price_mirror_rows', 'supplier_name'), + 'quantity' => ! $schema->hasColumn('product_purchase_price_mirror_rows', 'quantity'), + 'document_created_at' => ! $schema->hasColumn('product_purchase_price_mirror_rows', 'document_created_at'), + ]); + + if ($missing === []) { + return; + } + + $schema->table('product_purchase_price_mirror_rows', function (Blueprint $table) use ($missing): void { + if (isset($missing['source_order_no'])) { + $table->unsignedBigInteger('source_order_no')->nullable()->after('purchase_order_id'); + } + if (isset($missing['supplier_name'])) { + $table->string('supplier_name', 255)->nullable()->after('source_order_no'); + } + if (isset($missing['quantity'])) { + $table->decimal('quantity', 20, 4)->nullable()->after('style_id'); + } + if (isset($missing['document_created_at'])) { + $table->dateTime('document_created_at', 6)->nullable()->after('labels'); + } + }); + + $schema->table('product_purchase_price_mirror_rows', function (Blueprint $table): void { + $table->index('source_order_no', 'product_purchase_source_order_index'); + $table->index( + ['document_status', 'document_created_at', 'item_id'], + 'product_purchase_online_daifa_lookup', + ); + }); + } + + public function down(): void + { + if (app()->environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + + if ($schema->hasTable('product_purchase_price_mirror_rows')) { + $schema->table('product_purchase_price_mirror_rows', function (Blueprint $table): void { + $table->dropIndex('product_purchase_source_order_index'); + $table->dropIndex('product_purchase_online_daifa_lookup'); + $table->dropColumn(['source_order_no', 'supplier_name', 'quantity', 'document_created_at']); + }); + } + + if ($schema->hasTable('product_purchase_price_sync_states')) { + $schema->table('product_purchase_price_sync_states', function (Blueprint $table): void { + $table->dropColumn('projection_version'); + }); + } + } +}; diff --git a/database/seeds/JushuitanShippingDemoSeeder.php b/database/seeds/JushuitanShippingDemoSeeder.php new file mode 100644 index 00000000..d79c9166 --- /dev/null +++ b/database/seeds/JushuitanShippingDemoSeeder.php @@ -0,0 +1,68 @@ +copy()->subHours(++$i); + $vol = $L * $W * $H; + $sid = 9000000 + $i; + + $db->table('jst_oms_shipping')->updateOrInsert( + ['source_id' => $sid, 'sku_source_id' => $sid], + [ + 'express_no' => $expno, 'trade_no' => $tradeno, 'item_id' => $itemid, 'barcode' => $barcode, + 'product_name' => $pname, 'qty' => $qty, 'receiver_state' => $prov, 'buyer_nick' => '', + 'receiver_name' => $rname, 'shop_name' => $shop, 'express_name1' => $express, 'warehouse_name' => $wh, + 'ship_time' => $shipTime, 'freight' => $freight, 'online_weight' => $onlineW, 'actual_weight' => $actualW, + 'length_cm' => $L, 'width_cm' => $W, 'height_cm' => $H, 'volume_cm3' => $vol, + 'receiver_city' => $city, 'receiver_district' => $dist, 'supplier_name' => $supplier, + 'receiver_address' => $prov.$city.$dist.'示例路'.($i*7).'号', 'receiver_mobile' => $mobile, 'receiver_phone' => '', + 'express_name2' => $express, 'platform' => $platform, 'buyer_memo' => '', 'seller_memo' => '', + 'package_no' => 'PH'.$shipTime->format('Ymd').str_pad((string)$i,4,'0',STR_PAD_LEFT), + 'amount' => round($actualW * 9.9 + 39, 2), 'product_code' => $procode, + 'download_time' => $shipTime->copy()->addMinutes(30), 'pay_time' => $shipTime->copy()->subHours(3), + 'freight_rate' => 1.0, 'freight_formula' => '首重+续重', + 'weight_kg' => $actualW, 'settle_weight_kg' => max($actualW, round($vol/6000, 4)), + 'settle_volume_cm3' => $vol, 'recon_weight_kg' => $actualW, 'recon_volume_cm3' => $vol, + 'finished_barcode' => '', 'finished_name' => $pname, 'finished_supplier' => $supplier, + 'settle_freight' => $settleFreight, 'front_no' => '', + 'modified' => $shipTime, 'synced_at' => $now, + ] + ); + } + + $db->table('jst_sync_state')->updateOrInsert(['dataset_key' => 'jst.sctrade'], [ + 'watermark' => $now, 'status' => 'succeeded', 'last_succeeded_at' => $now, + 'updated_at' => $now, 'created_at' => $now, + ]); + + $this->command?->info('已写入 ' . count($samples) . ' 条发货明细演示数据(47 列,source_id 9000001 起)。'); + } +} diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 02cc6fe6..367cea34 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -35,7 +35,7 @@ RUN apt-get -o Acquire::Retries=3 update \ && a2enmod expires headers rewrite \ && rm -rf /var/lib/apt/lists/* -FROM composer:2.8@sha256:5248900ab8b5f7f880c2d62180e40960cd87f60149ec9a1abfd62ac72a02577c AS composer-bin +FROM m.daocloud.io/docker.io/library/composer:2.8@sha256:5248900ab8b5f7f880c2d62180e40960cd87f60149ec9a1abfd62ac72a02577c AS composer-bin FROM php-base AS vendor diff --git a/deploy/local-init.sh b/deploy/local-init.sh index 6dfa66b8..3b8b9b1a 100644 --- a/deploy/local-init.sh +++ b/deploy/local-init.sh @@ -384,6 +384,11 @@ fi echo "==> 自动检查本机临时引导库结构..." docker compose -f "$compose_file" exec -T -u www-data app php artisan optimize:clear >/dev/null +echo "==> 自动检查供应商应付本地维护库结构..." +docker compose -f "$compose_file" exec -T -u www-data app \ + php artisan migrate --database=supplier_payable_maintenance \ + --path=database/migrations/2026_09_17_000000_create_supplier_payable_override_tables.php \ + --force --no-ansi docker compose -f "$compose_file" exec -T -u www-data app \ php artisan local-bootstrap:update --no-ansi diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 45276cf0..2c90773c 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -20,6 +20,18 @@ services: depends_on: mysql: condition: service_healthy + environment: + # Local test data entry uses PATCH requests; do not let the global + # firewall heuristics block the maintenance-only test environment. + FIREWALL_ENABLED: "false" + SUPPLIER_PAYABLE_MAINTENANCE_DB_CONNECTION: supplier_payable_maintenance + SUPPLIER_PAYABLE_MAINTENANCE_DB_DRIVER: mysql + SUPPLIER_PAYABLE_MAINTENANCE_DB_HOST: mysql + SUPPLIER_PAYABLE_MAINTENANCE_DB_PORT: 3306 + SUPPLIER_PAYABLE_MAINTENANCE_DB_DATABASE: akaunting + SUPPLIER_PAYABLE_MAINTENANCE_DB_USERNAME: akaunting + SUPPLIER_PAYABLE_MAINTENANCE_DB_PASSWORD: akaunting123 + SUPPLIER_PAYABLE_MAINTENANCE_DB_PREFIX: ak_ ports: - "127.0.0.1:18088:18088" volumes: diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index e672dfb8..e5df0d3e 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -14,7 +14,7 @@ | 已完成 | `operations_replica/rpt.jst_sku` 已进入 `ops_jst_sku`,普通商品资料页面固定读取 `sku_type = normal` 的共享镜像,并支持稳定分页和业务筛选。 | | 本次接入 | 聚水潭进销存共享镜像读取 `jiansun/dbo.jst_inoutWater + dbo.jst_inoutWaterItem + dbo.jst_inventoryInfo` 的全部仓库,保留 2026-08-01 起的已确认流水与库存锚点。“快递应付 / 三方仓进销存”在查询层继续固定 `wms_co_id = 15045364`;“商品价格管理 / 进销存报表”按所有仓库、商品和月份汇总。 | | 已接入校准 | 聚水潭“普通商品资料”完整 XLSX 导出通过 `product-catalog:import-authoritative-export` 流式导入;导出中不存在的经营分析副本历史行写入 `product_catalog_exclusions`,页面与导出统一排除,但不删除 `ops_jst_sku` 原始镜像行。 | -| 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价,并与原商品采购价分别标注来源。 | +| 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价。线上代发价格页面另读取 `Confirmed` / `Finished` 采购明细,以 `source_o_id` 关联 `ops_jst_orders.o_id`,展示线上单号、店铺、供应商、商品编码、数量、单价并标记“采购单管理”;页面请求不直连上游。 | | 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 | | 本次接入 | 聚水潭聚工单协同工单登记 `work_order_main`、`work_order_sub`、`work_order_form_config`、`work_order_form_value` 四张表;工单 API 固定筛选类型“仓内未发”,首次同步完成前返回同步未完成状态。 | | 待首次同步 | 聚水潭聚工单“小额打款”读取 `jiansun.dbo.jst_workOrderInfo` 与已解析映射 `dbo.jst_small_payment_order_map`;财务经营报表的小额打款页面固定筛选工单类型 `82D94F5C54544EE3833CD8A3AB8B9F73`,页面只读共享镜像,首次同步完成前明确显示待同步状态。 | @@ -25,12 +25,14 @@ | 页面已切换 | 店铺收款账单的淘系支付宝账户读取 `operations_replica.rpt.jst_originalBillInfo`,并关联 `rpt.jst_shopInfo` 展示店铺;分页、筛选、汇总和导出统一读取共享业务镜像。 | | 抖音按月校准 | 抖音账户对已核对的完整月份读取 `douyin_authoritative_bills` 中胜算原始明细,`douyin_authoritative_periods` 原子选择有效版本;该月份不再叠加聚水潭拆分行。未校准月份继续读取原始账单镜像。导入命令为 `business-mirror:import-douyin-authoritative --count=<核对笔数> --income=<收入> --expense=<支出>`,只接受完整月份和精确核对值,保留旧版本、文件摘要及原镜像供回滚。生产执行前备份状态与受影响月份镜像,并使用 `/run/caiwu-upstream-sync.lock`。实际财务数据只放私有目录,不进入 Git。 | | 已接入待首次同步 | 店铺收款账单的拼多多账户读取 `jiansun.dbo.jst_originalBillInfo` 中固定 `channel = PinduoduoImport` 的独立切片镜像 `js_jst_pdd_receiving_bill_rows`;不使用慢一天的 `operations_replica` 副本或营销账户 `pdd_spread_bills`。 | -| 已接入待首次同步 | 店铺收款账单的得物账户读取 `jiansun.dbo.jst_originalBillInfo` 中固定 `channel IN (Dewu, DewuTK, DewuKJ)` 的独立切片镜像 `js_jst_dewu_receiving_bill_rows`;主账单、退货退款订单和扣减其他费用明细统一展示,`Dewubzj` 保证金继续排除。 | +| 已接入待首次导入 | 店铺收款账单的得物账户读取聚胜算权威 XLSX 经受控导入生成的 `dewu_settlement_bill_rows`;保留得物、退货退款订单和扣减其他费用明细三类渠道,不再回退 `jst_originalBillInfo` 辅助镜像。 | | 已完成首次导入 | 提现管理的每日店铺余额读取聚水潭“平台原始账单[新] → 店铺余额表”接口 JSON/XLSX、多平台“平台资金账户余额”导出及“京东商城 → 京东钱包”流水,进入 `jushuitan_shop_balance_snapshots`。京东钱包按前一天作为截止日,每个店铺取最新费用结算时间的最后一笔账户余额,同一时间以最大记录编码消歧;每天 05:00 原子覆盖各来源范围,异常或空数据保留上一快照。页面按店铺名称精确匹配并强制使用平台消歧,未匹配和多匹配不会显示为零。 | | 已接入待首次同步 | 店铺营销账户余额的京东账户读取 `js_data_middle_platform.dbo.jd_jzt_finance_records` 镜像 `jsm_jd_jzt_finance_records`;按 `id` 稳定断点、`updated_at` 重叠增量,页面不直连上游。 | | 本次接入 | 普通商品资料的工作平台采购价格接入 `jiansun.dbo.pm_priceAdjustment`(镜像 `js_pm_price_adjustment`):按 `skuCode` 关联 `jst_sku.sku_id`,取 `effectTime` 不晚于当前时间的最新 `purchasePrice`,并在页面和导出中标记“工作平台价格”;源表没有可靠更新时间,按 `Id` 稳定键追加,首次全量完成前只显示普通商品自身采购价。 | | 待有效凭据后首次同步 | 普通商品当前成本价、商品类型和更新时间继续读取 `operations_replica.rpt.jst_sku`;`rpt.fys_skuCostPriceUpdate` 是一 SKU 一行的旧快照,不能充当变价历史。完整成本价变更仍读取聚水潭经典开放平台“商品操作日志查询”接口 `worklog.query`,只镜像操作内容含“采购价”的记录到 `jushuitan_cost_price_history_rows`;经典接口必须在服务器私有配置中使用同一应用的 `JUSHUITAN_PARTNER_ID / JUSHUITAN_PARTNER_KEY / JUSHUITAN_TOKEN`,不得把新开放平台的 App Key / App Secret / Access Token 混用。页面在首次同步完成前明确显示待同步或失败,不把 0 条解释为真实无历史。 | +抖音月中校准可在上述导入命令追加 `--through=YYYY-MM-DD`,文件必须包含该月 1 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。 + ## 经营分析副本库(operations_replica) 店铺维度报表不使用订单件数代替销量。中央刷新节点按上游主键 `Id` @@ -74,7 +76,7 @@ | 快递资料 | `dbo.wp_express`、`dbo.wp_express_expNet`、`dbo.wp_express_account`、`dbo.wp_express_extension` | | 聚水潭小额打款工单 | `dbo.jst_workOrderInfo`(稳定键 `Id`、增量字段 `updateTime`)与 `dbo.jst_small_payment_order_map`(稳定键 `woNo`、增量字段 `updated_at`) | | 拼多多货款账户账单 | `dbo.jst_originalBillInfo`(固定 `channel = PinduoduoImport`,镜像 `js_jst_pdd_receiving_bill_rows`) | -| 得物货款账户账单 | `dbo.jst_originalBillInfo`(固定 `channel IN (Dewu, DewuTK, DewuKJ)`,镜像 `js_jst_dewu_receiving_bill_rows`;排除 `Dewubzj`) | +| 得物完整账户账单 | 聚胜算平台原始账单(新)XLSX(受控导入 `dewu_settlement_bill_rows`;保留得物、退货退款订单、扣减其他费用明细) | ## JSDataMiddlePlatform 中台库(js_data_middle_platform) @@ -92,12 +94,12 @@ 4. 普通商品在最近完整校准超过 168 小时后,于周日 02:00-08:00 低峰窗口启动全量校准;一旦启动,后续每分钟从稳定游标继续,直至完整结束。首次全量由 Codex 备份后自主执行,定时任务保持既有初始化开关。 5. 除专用数据集外,通用任务每轮默认只处理 1 个数据集、每个数据集 2 批,每批 500 行,避免同时压迫两个旧系统。 6. 页面切换必须以“总记录数、第一页、第二页、末页、筛选、汇总、空数据和更新时间”作为验收,不能只看首页有数据。 -7. 普通商品的采购单价同步以采购主表 `updateTime` 加明细 `Id` 作为稳定游标;页面按商品编码选择最近完成的 1688 一键代发价格。同步保存全部状态,使完成单后续取消时也能撤回旧价格;首次全量前只显示普通商品自身采购价。 +7. 普通商品的采购单同步以采购主表 `updateTime` 加明细 `Id` 作为稳定游标;镜像保存来源单号、供应商、数量、单价和全部状态。普通商品页面按商品编码选择最近完成的 1688 一键代发价格;线上代发价格页面只展示 `Confirmed` / `Finished`,并在共享镜像内关联订单。完成单后续取消时可撤回旧价格;新增投影字段完成新一轮全量前不展示采购单管理数据。 8. 聚水潭官方接口尚未接入时,普通商品历史残留排除由负责人提供的最新完整 XLSX 导出刷新。只排除“导出生成前已创建、但导出中缺失”的镜像行,导出生成后的新增商品保持可见。导入使用新同步代次,只有完整解析成功才原子替换排除表;中断或错误保留上一版。后续接入聚水潭官方“普通商品资料查询”及删除/恢复记录后,由官方接口取代人工导出校准。 9. 店铺维度报表是已批准的中央专用数据集:生产节点按 `Id` 断点推进并回看最近 50000 行;原始账单同步后增量维护按店铺/日期汇总的 `store_report_daily_rows`,页面不得现场扫描千万级原始账单。开发机不运行刷新 worker。 10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。 淘系支付宝账户账单当前仍读取此 MariaDB 镜像,统一调度须持续增量,不因 ClickHouse 尚未启用而暂停。账单冲减则复用“订单数据 → 原始数据 → 订单售后费用明细”,不按该表费用名称推断 500112。 11. 拼多多货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving` 数据集。同步器必须以参数绑定固定 `channel = PinduoduoImport`,只复制该渠道切片;页面 Repository 只读 `js_jst_pdd_receiving_bill_rows`。首次全量按 `Id` 断点推进,后续按 `updateTime` 保留重叠窗口,不得切回落后一天的 `operations_replica` 副本。 -12. 得物货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.dewu_receiving` 数据集。同步器必须以参数绑定固定 `channel IN (Dewu, DewuTK, DewuKJ)`,页面 Repository 对列表、分页、筛选、汇总和导出使用相同渠道范围,并保留原始渠道代码用于审计;`Dewubzj` 保证金不得混入。 +12. 得物完整账单使用聚胜算权威 XLSX,通过 `dewu-bills:import-authoritative` 写入 `dewu_settlement_bill_rows`。导入必须核验文件 SHA-256、账期、记录编码唯一性、行数、收入和支出控制数,并保留得物、退货退款订单、扣减其他费用明细三类渠道;页面 Repository 不得回退 `jiansun.dbo.jst_originalBillInfo.dewu_receiving`。详细流程见 `docs/得物完整账单取数方案留档.md`。 13. 聚水潭进销存首次全量按头表 `Id` 区间小批断点续传,头表保存全部仓库中 `io_date >= 2026-08-01` 的数据,不激活半份数据;日常增量以头表 `(modified, Id)` 作稳定游标并保留 48 小时时间重叠,由命中头单驱动整单明细替换。全量和增量都先写待发布代次,库存抓取完成后再把流水追平到库存抓取之后的固定源时间边界,三部分成功后才一次切换;运行中或失败时三方仓页面继续读取上一成功代次,全仓报表在首份完整全仓代次发布前明确提示同步中。页面只统计 `Confirmed` 的 12 类进销存流水,数量按上游 `qty * qty_type` 保留符号。库存同时保存上游 `source_modified_at` 和实际 `captured_at`,历史余额以每个“仓库 + SKU”的抓取时间为回推锚点;三方仓页面仍固定 `wms_co_id = 15045364` 并按“日期 + 商品”输出,全仓报表按“月份 + 仓库 + 商品”汇总。每天 `00:00` 执行增量同步,失败时继续展示上一成功代次。 14. 聚水潭商品操作日志按最多 7 天的固定时间窗和接口页码稳定断点同步,每页最多 50 条,首次全量从 `2000-01-01` 起扫描;只保留备注内容含“采购价”的记录,并从“采购价:旧值->新值”解析前后价格和差额。日常同步回看 48 小时,按接口记录字段生成稳定键幂等更新;接口失败时保留上一版镜像。页面不得把这些聚水潭记录与 `jiansun.dbo.pm_priceAdjustment` 的工作平台价格记录混为同一来源。 diff --git a/docs/得物完整账单取数方案留档.md b/docs/得物完整账单取数方案留档.md new file mode 100644 index 00000000..3ff73fbc --- /dev/null +++ b/docs/得物完整账单取数方案留档.md @@ -0,0 +1,59 @@ +# 得物完整账单取数方案留档 + +## 目标 + +财务平台“账单流水数据 / 店铺收款账户账单 / 得物账户”以聚胜算导出的平台原始账单为权威来源,不再回退到 `rpt.jst_originalBillInfo` 或其得物辅助镜像。 + +页面现有表头和列顺序保持不变,完整保留以下渠道: + +- 得物 +- 退货退款订单 +- 扣减其他费用明细 + +`Dewubzj` 保证金不属于本账单文件,不混入得物收支账单。 + +## 权威文件 + +来源固定为聚胜算“处理 / 平台原始账单(新) / 得物”导出的 XLSX。导入要求标准表头完整存在: + +`记录编码、订单号、线上单号、自定义备注、账单项目、店铺、订单类型、商品名称、商品货号、数量、渠道、业务类型、收入金额、支出金额、线上单号对应实际店铺、订单创建时间、支付/退款时间、发货/退货时间、结算状态、收款账期、拉取时间、修改时间、修改人`。 + +得物文件没有单独“金额”列,系统以 `收入金额 - 支出金额` 计算净额。账期以“发货/退货时间”为主;该字段为空时才使用“支付/退款时间”。两个原始时间字段均单独保存,页面不会伪造支付/退款时间。 + +## 数据表 + +共享业务镜像库保存三张专用表: + +- `dewu_settlement_bill_imports`:源文件名、SHA-256、账期、预期和实际控制数、渠道计数、状态。 +- `dewu_settlement_bill_staging`:当前导入代次的暂存数据。 +- `dewu_settlement_bill_rows`:页面和导出读取的权威正式数据。 + +页面 Repository 只读 `dewu_settlement_bill_rows`。尚未成功导入时返回明确的 503,不回退旧镜像。 + +## 导入控制 + +导入前必须同时提供账期、行数、收入和支出控制数: + +```bash +php artisan dewu-bills:import-authoritative /protected/path/dewu.xlsx \ + --from=2026-08-01 \ + --to=2026-08-31 \ + --expected-count=68449 \ + --expected-income=1236493.96 \ + --expected-expense=634104.33 +``` + +导入必须满足: + +1. 文件为可读 XLSX,标准表头完整。 +2. 记录编码非空且在同一文件中唯一。 +3. 每行渠道非空,账单时间非空且位于指定账期。 +4. 实际行数、收入、支出与权威控制数完全一致。 +5. 同一 SHA-256 已成功导入时幂等跳过;同一文件使用不同账期或控制数时拒绝。 +6. 先写暂存表并完成核验,再在事务内替换指定账期;任何失败均保留上一版正式数据。 + +## 每日运行 + +每日北京时间 08:00 处理前一自然日已生成的得物完整账单,不处理当天数据。自动任务必须先归档源文件并记录 SHA-256 与控制数,再调用受控导入命令。源文件未生成、下载失败、会话失效、控制数不一致、生产连接不可用或迁移未完成时停止,不使用旧镜像补数。 + +上线前需单独完成生产备份、迁移授权、发布以及首次导入授权。回滚时使用已归档的上一版源文件及其控制数重新导入相同账期。 diff --git a/modules/EcommerceFinance/AccountBillColumns.php b/modules/EcommerceFinance/AccountBillColumns.php index c0bd07f8..c72f7f22 100644 --- a/modules/EcommerceFinance/AccountBillColumns.php +++ b/modules/EcommerceFinance/AccountBillColumns.php @@ -24,6 +24,40 @@ class AccountBillColumns public static function map(): array { return [ + 'vipshop' => [ + 'title' => '唯品会账户', + 'columns' => [ + ['全局ID', 'record_code', 'num'], + ['店铺ID', 'payload.shop_id', 'num'], + ['店铺名称', 'store_name', 'text'], + ['订单签收或退款时间', 'occurred_at', 'date'], + ['订单类型名称', 'bill_item', 'text'], + ['行类型名称', 'payload.vip_5', 'text'], + ['售后类型名称', 'payload.vip_6', 'text'], + ['售后单号', 'payload.vip_7', 'text'], + ['订单编号', 'order_no', 'num'], + ['SKU', 'payload.vip_9', 'num'], + ['商品款号', 'payload.vip_10', 'num'], + ['商品SKU', 'payload.vip_11', 'num'], + ['商品描述', 'payload.vip_12', 'text'], + ['店铺协议编号', 'payload.vip_13', 'text'], + ['二级类目', 'payload.vip_14', 'text'], + ['三级类目', 'payload.vip_15', 'text'], + ['商品单价', 'payload.vip_16', 'money'], + ['商品数量', 'payload.vip_17', 'num'], + ['商品金额(A)', 'payload.vip_18', 'money'], + ['商家优惠(B)', 'payload.vip_19', 'money'], + ['调整金额(C)', 'payload.vip_20', 'money'], + ['无需退款金额(D)', 'payload.vip_21', 'money'], + ['总金额(E=A-B-C-D)', 'payload.vip_22', 'money'], + ['扣点', 'payload.vip_23', 'text'], + ['技术服务费(F)', 'payload.vip_24', 'money'], + ['海淘综合税(G)', 'payload.vip_25', 'money'], + ['分享金扣点', 'payload.vip_26', 'text'], + ['唯品仓分享金', 'payload.vip_27', 'money'], + ['商家应收(G=E-F-G)', 'balance', 'money'], + ], + ], 'taobao-wechat' => [ 'title' => '淘系微信账户', 'groups' => [ @@ -106,7 +140,7 @@ class AccountBillColumns ], 'jd-transactions' => [ - 'title' => '京东流水', + 'title' => '京东账单', 'columns' => [ ['记录编码', 'record_code', 'num'], ['店铺', 'store_name', 'text'], diff --git a/modules/EcommerceFinance/Console/SyncJushuitanShipping.php b/modules/EcommerceFinance/Console/SyncJushuitanShipping.php new file mode 100644 index 00000000..b782fccd --- /dev/null +++ b/modules/EcommerceFinance/Console/SyncJushuitanShipping.php @@ -0,0 +1,37 @@ +option('from') ? Carbon::parse($this->option('from')) : null; + $to = $this->option('to') ? Carbon::parse($this->option('to')) : null; + + $this->info('开始同步聚水潭发货数据...'); + try { + $result = ShippingDataSynchronizer::make()->syncIncremental($from, $to); + } catch (\Throwable $e) { + $this->error('同步失败:' . $e->getMessage()); + return self::FAILURE; + } + + $this->info(sprintf('完成:处理 %d 单,水位推进到 %s', $result['processed'], $result['watermark'] ?? '(未变)')); + return self::SUCCESS; + } +} diff --git a/modules/EcommerceFinance/Database/Migrations/2026_09_15_100001_create_jst_sctrade_tables.php b/modules/EcommerceFinance/Database/Migrations/2026_09_15_100001_create_jst_sctrade_tables.php new file mode 100644 index 00000000..3f1774a4 --- /dev/null +++ b/modules/EcommerceFinance/Database/Migrations/2026_09_15_100001_create_jst_sctrade_tables.php @@ -0,0 +1,129 @@ +connection()); + + // —— 主单 —— + if (! $schema->hasTable('jst_sctrade')) $schema->create('jst_sctrade', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger('source_id')->comment('聚水潭单据 id'); // 稳定键 + $table->string('sourceno', 64)->nullable()->comment('交易/线上单号'); + $table->string('io_id', 64)->nullable()->comment('出库单号'); + $table->string('shop_id', 32)->nullable()->comment('店铺 id'); + $table->string('shopcode', 64)->nullable()->comment('店铺编码'); + $table->string('wms_co_id', 32)->nullable()->comment('分仓/仓库 id'); + $table->string('platform', 64)->nullable()->comment('平台'); + $table->string('status', 32)->nullable()->comment('单据状态'); + $table->dateTime('paytime')->nullable(); + $table->dateTime('sended')->nullable()->comment('发货时间'); + $table->dateTime('created')->nullable(); + $table->dateTime('modified')->nullable()->comment('修改时间(增量水位)'); + // 收货人(主单上冗余的部分,明细以 receiver 表为准) + $table->string('receiverstate', 64)->nullable(); + $table->string('receivercity', 64)->nullable(); + $table->string('receiverdistrict', 64)->nullable(); + $table->string('receivername', 128)->nullable(); + $table->string('receivermobile', 64)->nullable(); + $table->string('receiverphone', 64)->nullable(); + $table->longText('receiveraddress')->nullable(); + $table->decimal('weight', 12, 4)->nullable(); + $table->decimal('freight', 12, 2)->nullable(); + $table->longText('raw_json')->nullable()->comment('完整报文保真'); + $table->dateTime('synced_at', 6)->nullable(); + + $table->unique('source_id'); + $table->index('sourceno'); + $table->index('modified'); + $table->index('sended'); + }); + + // —— 平台商品明细 —— + if (! $schema->hasTable('jst_sctrade_items')) $schema->create('jst_sctrade_items', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger('source_id')->comment('明细 id'); + $table->unsignedBigInteger('hid')->comment('主单 source_id'); + $table->string('oid', 64)->nullable()->comment('平台子订单号'); + $table->string('shopproid', 64)->nullable(); + $table->string('shoprocode', 128)->nullable(); + $table->string('shopskucode', 128)->nullable(); + $table->string('shoproname', 512)->nullable(); + $table->integer('qty')->nullable(); + $table->longText('raw_json')->nullable(); + $table->dateTime('synced_at', 6)->nullable(); + + $table->unique('source_id'); + $table->index('hid'); + }); + + // —— SKU 明细 —— + if (! $schema->hasTable('jst_sctrade_skus')) $schema->create('jst_sctrade_skus', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger('source_id')->comment('SKU 明细 id'); + $table->unsignedBigInteger('hid')->comment('主单 source_id'); + $table->string('procode', 128)->nullable()->comment('商品编码/货号'); + $table->string('sku_id', 128)->nullable(); + $table->string('proname', 512)->nullable(); + $table->string('scmname', 512)->nullable()->comment('成品名'); + $table->string('postcode', 64)->nullable()->comment('快递单号'); + $table->string('logistics_company', 128)->nullable()->comment('快递公司'); + $table->integer('qty')->nullable(); + $table->dateTime('sended')->nullable(); + $table->longText('raw_json')->nullable(); + $table->dateTime('synced_at', 6)->nullable(); + + $table->unique('source_id'); + $table->index('hid'); + $table->index('postcode'); + }); + + // —— 收货人明细 —— + if (! $schema->hasTable('jst_sctrade_receiver')) $schema->create('jst_sctrade_receiver', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger('hid')->comment('主单 source_id'); + $table->string('receivername', 128)->nullable(); + $table->string('receiverstate', 64)->nullable(); + $table->string('receivercity', 64)->nullable(); + $table->string('receiverdistrict', 64)->nullable(); + $table->longText('receiveraddress')->nullable(); + $table->string('receivermobile', 64)->nullable(); + $table->string('receiverphone', 64)->nullable(); + $table->string('receiverzip', 32)->nullable(); + $table->longText('raw_json')->nullable(); + $table->dateTime('synced_at', 6)->nullable(); + + $table->unique('hid'); + }); + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + $schema->dropIfExists('jst_sctrade_receiver'); + $schema->dropIfExists('jst_sctrade_skus'); + $schema->dropIfExists('jst_sctrade_items'); + $schema->dropIfExists('jst_sctrade'); + } +}; diff --git a/modules/EcommerceFinance/Database/Migrations/2026_09_15_100002_create_jst_sync_state_table.php b/modules/EcommerceFinance/Database/Migrations/2026_09_15_100002_create_jst_sync_state_table.php new file mode 100644 index 00000000..547cc346 --- /dev/null +++ b/modules/EcommerceFinance/Database/Migrations/2026_09_15_100002_create_jst_sync_state_table.php @@ -0,0 +1,36 @@ +connection())->hasTable('jst_sync_state')) return; + Schema::connection($this->connection())->create('jst_sync_state', function (Blueprint $table) { + $table->string('dataset_key', 128)->primary(); // 如 jst.sctrade + $table->dateTime('watermark')->nullable(); // 已同步到的 modified 时间 + $table->string('status', 20)->default('pending'); // pending/running/succeeded/failed + $table->dateTime('last_started_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists('jst_sync_state'); + } +}; diff --git a/modules/EcommerceFinance/Database/Migrations/2026_09_15_110001_create_jst_oms_shipping_table.php b/modules/EcommerceFinance/Database/Migrations/2026_09_15_110001_create_jst_oms_shipping_table.php new file mode 100644 index 00000000..c4bd82c0 --- /dev/null +++ b/modules/EcommerceFinance/Database/Migrations/2026_09_15_110001_create_jst_oms_shipping_table.php @@ -0,0 +1,89 @@ +conn()); + if ($schema->hasTable('jst_oms_shipping')) return; + + $schema->create('jst_oms_shipping', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger('source_id')->nullable()->comment('聚水潭单据 id'); + $table->unsignedBigInteger('sku_source_id')->nullable()->comment('聚水潭 SKU 明细 id'); + + // —— 按界面顺序的 47 列 —— + $table->string('express_no', 64)->nullable()->comment('1 快递单号'); + $table->string('trade_no', 64)->nullable()->comment('2 交易单号'); + $table->string('item_id', 64)->nullable()->comment('3 商品ID'); + $table->string('barcode', 128)->nullable()->comment('4 条形码'); + $table->string('product_name', 512)->nullable()->comment('5 商品名称'); + $table->integer('qty')->nullable()->comment('6 数量'); + $table->string('receiver_state', 64)->nullable()->comment('7 省'); + $table->string('buyer_nick', 128)->nullable()->comment('8 买家昵称'); + $table->string('receiver_name', 128)->nullable()->comment('9 收货姓名'); + $table->string('shop_name', 255)->nullable()->comment('10 店铺名称'); + $table->string('express_name1', 128)->nullable()->comment('11 快递名称1'); + $table->string('warehouse_name', 128)->nullable()->comment('12 仓库'); + $table->dateTime('ship_time')->nullable()->comment('13 发货时间'); + $table->decimal('freight', 18, 2)->nullable()->comment('14 运费'); + $table->decimal('online_weight', 18, 4)->nullable()->comment('15 线上重量'); + $table->decimal('actual_weight', 18, 4)->nullable()->comment('16 实称重量'); + $table->decimal('length_cm', 18, 2)->nullable()->comment('17 长'); + $table->decimal('width_cm', 18, 2)->nullable()->comment('18 宽'); + $table->decimal('height_cm', 18, 2)->nullable()->comment('19 高'); + $table->decimal('volume_cm3', 18, 4)->nullable()->comment('20 体积'); + $table->string('receiver_city', 64)->nullable()->comment('21 市'); + $table->string('receiver_district', 64)->nullable()->comment('22 区'); + $table->string('supplier_name', 128)->nullable()->comment('23 供应商'); + $table->longText('receiver_address')->nullable()->comment('24 收货详细地址'); + $table->string('receiver_mobile', 64)->nullable()->comment('25 手机号码'); + $table->string('receiver_phone', 64)->nullable()->comment('26 座机号码'); + $table->string('express_name2', 128)->nullable()->comment('27 快递名称2'); + $table->string('platform', 64)->nullable()->comment('28 平台'); + $table->longText('buyer_memo')->nullable()->comment('29 买家留言'); + $table->longText('seller_memo')->nullable()->comment('30 卖家备注'); + $table->string('package_no', 64)->nullable()->comment('31 配货单号'); + $table->decimal('amount', 18, 2)->nullable()->comment('32 金额'); + $table->string('product_code', 128)->nullable()->comment('33 货号'); + $table->dateTime('download_time')->nullable()->comment('34 下载时间'); + $table->dateTime('pay_time')->nullable()->comment('35 付款时间'); + $table->decimal('freight_rate', 18, 4)->nullable()->comment('36 费率'); + $table->string('freight_formula', 255)->nullable()->comment('37 公式'); + $table->decimal('weight_kg', 18, 4)->nullable()->comment('38 重量'); + $table->decimal('settle_weight_kg', 18, 4)->nullable()->comment('39 结算重量'); + $table->decimal('settle_volume_cm3', 18, 4)->nullable()->comment('40 结算体积'); + $table->decimal('recon_weight_kg', 18, 4)->nullable()->comment('41 对账重量'); + $table->decimal('recon_volume_cm3', 18, 4)->nullable()->comment('42 对账体积'); + $table->string('finished_barcode', 128)->nullable()->comment('43 成品条码'); + $table->string('finished_name', 512)->nullable()->comment('44 成品名称'); + $table->string('finished_supplier', 128)->nullable()->comment('45 成品供应商'); + $table->decimal('settle_freight', 18, 2)->nullable()->comment('46 结算运费'); + $table->string('front_no', 64)->nullable()->comment('47 换单前单号'); + + $table->dateTime('modified')->nullable()->comment('增量水位'); + $table->dateTime('synced_at', 6)->nullable(); + + $table->index('express_no'); + $table->index('trade_no'); + $table->index('ship_time'); + $table->unique(['source_id', 'sku_source_id'], 'jst_oms_uniq'); + }); + } + + public function down(): void + { + Schema::connection($this->conn())->dropIfExists('jst_oms_shipping'); + } +}; diff --git a/modules/EcommerceFinance/Http/Controllers/AccountBills.php b/modules/EcommerceFinance/Http/Controllers/AccountBills.php index d4ce3a67..88c0a39f 100644 --- a/modules/EcommerceFinance/Http/Controllers/AccountBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AccountBills.php @@ -378,7 +378,7 @@ class AccountBills extends Controller private function mirrorLabel(string $billType): string { return match ($billType) { - 'jd-transactions' => '京东日账单', + 'jd-transactions' => '京东账单', 'jd-wallet' => '京东钱包', 'pinduoduo' => '拼多多', 'dewu' => '得物', diff --git a/modules/EcommerceFinance/Http/Controllers/Api/GoodsReconciliations.php b/modules/EcommerceFinance/Http/Controllers/Api/GoodsReconciliations.php index dfa53775..9cc94795 100644 --- a/modules/EcommerceFinance/Http/Controllers/Api/GoodsReconciliations.php +++ b/modules/EcommerceFinance/Http/Controllers/Api/GoodsReconciliations.php @@ -5,6 +5,7 @@ namespace Modules\EcommerceFinance\Http\Controllers\Api; use App\Abstracts\Http\ApiController; use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository; use Modules\EcommerceFinance\Services\JianSunQueryCache; +use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore; /** * 货款对账 API @@ -20,7 +21,7 @@ class GoodsReconciliations extends ApiController private JianSunQueryCache $cache; - public function __construct(GoodsReconciliationRepository $repo, JianSunQueryCache $cache) + public function __construct(GoodsReconciliationRepository $repo, JianSunQueryCache $cache, private readonly SupplierPayableOverrideStore $overrides) { $this->repo = $repo; $this->cache = $cache; @@ -41,6 +42,8 @@ class GoodsReconciliations extends ApiController fn () => $this->repo->list($filters, $page, $limit) ); + $result['data'] = $this->overrides->decorate('payment-reconciliation', $result['data'] ?? [], (int) (company_id() ?: 1)); + return $this->snapshotResponse([ 'data' => $result['data'], 'total' => $result['total'], diff --git a/modules/EcommerceFinance/Http/Controllers/Api/SupplierPayables.php b/modules/EcommerceFinance/Http/Controllers/Api/SupplierPayables.php index c8401e4d..8d84c164 100644 --- a/modules/EcommerceFinance/Http/Controllers/Api/SupplierPayables.php +++ b/modules/EcommerceFinance/Http/Controllers/Api/SupplierPayables.php @@ -6,6 +6,7 @@ use App\Abstracts\Http\ApiController; use Closure; use Modules\EcommerceFinance\Repositories\SupplierPayableRepository; use Modules\EcommerceFinance\Services\JianSunQueryCache; +use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore; /** * 供应商应付 - 子功能页面 API @@ -23,7 +24,7 @@ class SupplierPayables extends ApiController private JianSunQueryCache $cache; - public function __construct(SupplierPayableRepository $repo, JianSunQueryCache $cache) + public function __construct(SupplierPayableRepository $repo, JianSunQueryCache $cache, private readonly SupplierPayableOverrideStore $overrides) { $this->repo = $repo; $this->cache = $cache; @@ -333,12 +334,18 @@ class SupplierPayables extends ApiController { $page = $this->page(); $limit = $this->limit(); + $cacheParameters = ['filters' => $filters, 'page' => $page, 'limit' => $limit]; + if ($scope === 'other-adjustments') { + $cacheParameters['format_version'] = 2; + } $result = $this->cache->remember( $scope . '.list', - ['filters' => $filters, 'page' => $page, 'limit' => $limit], + $cacheParameters, fn () => $resolver($page, $limit) ); + $result['data'] = $this->overrides->decorate($scope, $result['data'] ?? [], (int) (company_id() ?: 1)); + return $this->snapshotResponse([ 'data' => $result['data'], 'total' => $result['total'], diff --git a/modules/EcommerceFinance/Http/Controllers/DewuAccountBills.php b/modules/EcommerceFinance/Http/Controllers/DewuAccountBills.php new file mode 100644 index 00000000..89e2cf1d --- /dev/null +++ b/modules/EcommerceFinance/Http/Controllers/DewuAccountBills.php @@ -0,0 +1,178 @@ +authorizeStoreReceivingBills(); + + if ($request->boolean('export')) { + return $this->exportResponse($request, $repository); + } + + $validated = $request->validate($this->filterRules() + [ + 'sort' => ['nullable', 'in:occurred_at,income,expense,record_code'], + 'dir' => ['nullable', 'in:asc,desc'], + 'page' => ['nullable', 'integer', 'min:1'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + $filters = collect($validated)->only(array_keys($this->filterRules()))->all(); + + try { + $data = $repository->paginateAccountBills( + $filters, + (string) ($validated['sort'] ?? 'occurred_at'), + (string) ($validated['dir'] ?? 'desc'), + (int) ($validated['page'] ?? 1), + (int) ($validated['limit'] ?? 10), + ); + } catch (RuntimeException $exception) { + if ($exception->getMessage() !== 'dewu_authoritative_bills_not_ready') { + throw $exception; + } + + return response()->json([ + 'message' => '得物完整账单尚未完成首次导入,当前不会回退到不完整的聚水潭辅助表。', + 'data' => [], + 'total' => 0, + 'current_page' => 1, + 'last_page' => 1, + 'per_page' => (int) ($validated['limit'] ?? 10), + 'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0], + 'columns' => AccountBillColumns::for('dewu'), + ], 503); + } + + $data['columns'] = AccountBillColumns::for('dewu'); + + return response()->json($data); + } + + public function filterOptions(DewuSettlementBillRepository $repository) + { + $this->authorizeStoreReceivingBills(); + + try { + return response()->json($repository->accountBillFilterOptions()); + } catch (RuntimeException $exception) { + if ($exception->getMessage() !== 'dewu_authoritative_bills_not_ready') { + throw $exception; + } + + return response()->json([ + 'message' => '得物完整账单尚未完成首次导入。', + 'shops' => [], + 'bill_items' => [], + ], 503); + } + } + + public function export(Request $request, DewuSettlementBillRepository $repository) + { + $this->authorizeStoreReceivingBills(); + + return $this->exportResponse($request, $repository); + } + + private function exportResponse(Request $request, DewuSettlementBillRepository $repository) + { + $validated = $request->validate($this->filterRules() + [ + 'sort' => ['nullable', 'in:occurred_at,income,expense,record_code'], + 'dir' => ['nullable', 'in:asc,desc'], + ]); + $filters = collect($validated)->only(array_keys($this->filterRules()))->all(); + $definition = AccountBillColumns::for('dewu'); + $available = collect($definition['columns'] ?? [])->keyBy('field'); + $requested = $request->query('columns', []); + if (is_string($requested)) { + $requested = array_filter(array_map('trim', explode(',', $requested))); + } + if (! is_array($requested) || count($requested) > 40) { + $requested = []; + } + $requested = collect($requested) + ->filter(static fn (mixed $value): bool => is_scalar($value)) + ->map(static fn (mixed $value): string => mb_substr(trim((string) $value), 0, 128)) + ->filter() + ->unique(); + $columns = $requested->isEmpty() + ? $available->values() + : $requested->map(fn (string $field) => $available->get($field))->filter()->values(); + abort_if($columns->isEmpty(), 422, '至少选择一个可导出的字段。'); + + try { + $rows = $repository->exportAccountBills( + $filters, + (string) ($validated['sort'] ?? 'occurred_at'), + (string) ($validated['dir'] ?? 'asc'), + ); + } catch (RuntimeException $exception) { + if ($exception->getMessage() !== 'dewu_authoritative_bills_not_ready') { + throw $exception; + } + + return response()->json(['message' => '得物完整账单尚未完成首次导入。'], 503); + } + + return response()->streamDownload(function () use ($rows, $columns): void { + $csv = fopen('php://output', 'w'); + fputs($csv, "\xEF\xBB\xBF"); + fputcsv($csv, $columns->pluck('label')->all()); + foreach ($rows as $row) { + fputcsv($csv, $columns->map( + fn (array $column): string => $this->exportValue($row, $column) + )->all()); + } + fclose($csv); + }, 'dewu_account_bills.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + + /** @return array> */ + private function filterRules(): array + { + return [ + 'store_name' => ['nullable', 'string', 'max:255'], + 'bill_item' => ['nullable', 'string', 'max:255'], + 'start' => ['nullable', 'date_format:Y-m-d'], + 'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'], + 'keyword' => ['nullable', 'string', 'max:255'], + ]; + } + + /** @param array $row */ + private function exportValue(array $row, array $column): string + { + $field = (string) ($column['field'] ?? ''); + $value = str_starts_with($field, 'payload.') + ? data_get($row['payload'] ?? [], substr($field, 8)) + : data_get($row, $field); + $text = (string) ($value ?? ''); + if (preg_match('/^\d{12,}$/', $text) === 1) { + return '="' . $text . '"'; + } + + return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text; + } + + private function authorizeStoreReceivingBills(): void + { + $section = collect(SidebarPermissions::navigationFor(user())) + ->firstWhere('slug', 'billing-transactions'); + $page = collect($section['pages'] ?? []) + ->firstWhere('slug', 'store-receiving-bills'); + + abort_if(empty($page), 403, '当前账号没有店铺收款账户账单权限。'); + } +} diff --git a/modules/EcommerceFinance/Http/Controllers/PinduoduoAccountBills.php b/modules/EcommerceFinance/Http/Controllers/PinduoduoAccountBills.php new file mode 100644 index 00000000..c990561b --- /dev/null +++ b/modules/EcommerceFinance/Http/Controllers/PinduoduoAccountBills.php @@ -0,0 +1,178 @@ +authorizeStoreReceivingBills(); + + if ($request->boolean('export')) { + return $this->exportResponse($request, $repository); + } + + $validated = $request->validate($this->filterRules() + [ + 'sort' => ['nullable', 'in:occurred_at,income,expense,record_code'], + 'dir' => ['nullable', 'in:asc,desc'], + 'page' => ['nullable', 'integer', 'min:1'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + $filters = collect($validated)->only(array_keys($this->filterRules()))->all(); + + try { + $data = $repository->paginateAccountBills( + $filters, + (string) ($validated['sort'] ?? 'occurred_at'), + (string) ($validated['dir'] ?? 'desc'), + (int) ($validated['page'] ?? 1), + (int) ($validated['limit'] ?? 10), + ); + } catch (RuntimeException $exception) { + if ($exception->getMessage() !== 'pinduoduo_authoritative_bills_not_ready') { + throw $exception; + } + + return response()->json([ + 'message' => '拼多多完整货款账单尚未完成首次导入,当前不会回退到不完整的聚水潭辅助表。', + 'data' => [], + 'total' => 0, + 'current_page' => 1, + 'last_page' => 1, + 'per_page' => (int) ($validated['limit'] ?? 10), + 'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0], + 'columns' => AccountBillColumns::for('pinduoduo'), + ], 503); + } + + $data['columns'] = AccountBillColumns::for('pinduoduo'); + + return response()->json($data); + } + + public function filterOptions(PinduoduoSettlementBillRepository $repository) + { + $this->authorizeStoreReceivingBills(); + + try { + return response()->json($repository->accountBillFilterOptions()); + } catch (RuntimeException $exception) { + if ($exception->getMessage() !== 'pinduoduo_authoritative_bills_not_ready') { + throw $exception; + } + + return response()->json([ + 'message' => '拼多多完整货款账单尚未完成首次导入。', + 'shops' => [], + 'bill_items' => [], + ], 503); + } + } + + public function export(Request $request, PinduoduoSettlementBillRepository $repository) + { + $this->authorizeStoreReceivingBills(); + + return $this->exportResponse($request, $repository); + } + + private function exportResponse(Request $request, PinduoduoSettlementBillRepository $repository) + { + $validated = $request->validate($this->filterRules() + [ + 'sort' => ['nullable', 'in:occurred_at,income,expense,record_code'], + 'dir' => ['nullable', 'in:asc,desc'], + ]); + $filters = collect($validated)->only(array_keys($this->filterRules()))->all(); + $definition = AccountBillColumns::for('pinduoduo'); + $available = collect($definition['columns'] ?? [])->keyBy('field'); + $requested = $request->query('columns', []); + if (is_string($requested)) { + $requested = array_filter(array_map('trim', explode(',', $requested))); + } + if (! is_array($requested) || count($requested) > 40) { + $requested = []; + } + $requested = collect($requested) + ->filter(static fn (mixed $value): bool => is_scalar($value)) + ->map(static fn (mixed $value): string => mb_substr(trim((string) $value), 0, 128)) + ->filter() + ->unique(); + $columns = $requested->isEmpty() + ? $available->values() + : $requested->map(fn (string $field) => $available->get($field))->filter()->values(); + abort_if($columns->isEmpty(), 422, '至少选择一个可导出的字段。'); + + try { + $rows = $repository->exportAccountBills( + $filters, + (string) ($validated['sort'] ?? 'occurred_at'), + (string) ($validated['dir'] ?? 'asc'), + ); + } catch (RuntimeException $exception) { + if ($exception->getMessage() !== 'pinduoduo_authoritative_bills_not_ready') { + throw $exception; + } + + return response()->json(['message' => '拼多多完整货款账单尚未完成首次导入。'], 503); + } + + return response()->streamDownload(function () use ($rows, $columns): void { + $csv = fopen('php://output', 'w'); + fputs($csv, "\xEF\xBB\xBF"); + fputcsv($csv, $columns->pluck('label')->all()); + foreach ($rows as $row) { + fputcsv($csv, $columns->map( + fn (array $column): string => $this->exportValue($row, $column) + )->all()); + } + fclose($csv); + }, 'pinduoduo_account_bills.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + + /** @return array> */ + private function filterRules(): array + { + return [ + 'store_name' => ['nullable', 'string', 'max:255'], + 'bill_item' => ['nullable', 'string', 'max:255'], + 'start' => ['nullable', 'date_format:Y-m-d'], + 'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'], + 'keyword' => ['nullable', 'string', 'max:255'], + ]; + } + + /** @param array $row */ + private function exportValue(array $row, array $column): string + { + $field = (string) ($column['field'] ?? ''); + $value = str_starts_with($field, 'payload.') + ? data_get($row['payload'] ?? [], substr($field, 8)) + : data_get($row, $field); + $text = (string) ($value ?? ''); + if (preg_match('/^\d{12,}$/', $text) === 1) { + return '="' . $text . '"'; + } + + return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text; + } + + private function authorizeStoreReceivingBills(): void + { + $section = collect(SidebarPermissions::navigationFor(user())) + ->firstWhere('slug', 'billing-transactions'); + $page = collect($section['pages'] ?? []) + ->firstWhere('slug', 'store-receiving-bills'); + + abort_if(empty($page), 403, '当前账号没有店铺收款账户账单权限。'); + } +} diff --git a/modules/EcommerceFinance/Providers/Main.php b/modules/EcommerceFinance/Providers/Main.php index d3f51ced..c2564bd6 100644 --- a/modules/EcommerceFinance/Providers/Main.php +++ b/modules/EcommerceFinance/Providers/Main.php @@ -3,6 +3,7 @@ namespace Modules\EcommerceFinance\Providers; use Illuminate\Support\ServiceProvider as Provider; +use Modules\EcommerceFinance\Console\SyncJushuitanShipping; use Modules\EcommerceFinance\Services\JianSunSnapshotRefresher; use Modules\EcommerceFinance\Services\OperationsReplicaSnapshotRefresher; use Modules\EcommerceFinance\Services\UpstreamSnapshotRefresherRegistry; @@ -14,6 +15,12 @@ class Main extends Provider { $this->loadTranslationsFrom(__DIR__ . '/../Resources/lang', 'ecommerce-finance'); $this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations'); + + if ($this->app->runningInConsole()) { + $this->commands([ + SyncJushuitanShipping::class, + ]); + } } public function register(): void diff --git a/modules/EcommerceFinance/Repositories/DewuSettlementBillRepository.php b/modules/EcommerceFinance/Repositories/DewuSettlementBillRepository.php new file mode 100644 index 00000000..e2105465 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/DewuSettlementBillRepository.php @@ -0,0 +1,322 @@ +connection = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + public function ready(): bool + { + $schema = Schema::connection($this->connection->getName()); + if (! $schema->hasTable(self::IMPORT_TABLE) || ! $schema->hasTable(self::ROW_TABLE)) { + return false; + } + + return $this->connection->table(self::IMPORT_TABLE)->where('status', 'succeeded')->exists(); + } + + /** @return string[] */ + public function storeOptions(): array + { + return $this->query()->whereNotNull('store_name')->where('store_name', '<>', '') + ->distinct()->orderBy('store_name')->pluck('store_name')->map('strval')->all(); + } + + /** @return array{shops:list,bill_items:list} */ + public function accountBillFilterOptions(): array + { + $shops = $this->query()->whereNotNull('store_name')->where('store_name', '<>', '') + ->distinct()->orderBy('store_name')->pluck('store_name') + ->map(static fn (mixed $value): string => (string) $value)->all(); + $billItems = $this->query()->whereNotNull('bill_item')->where('bill_item', '<>', '') + ->distinct()->orderBy('bill_item')->pluck('bill_item') + ->map(static fn (mixed $value): string => (string) $value)->all(); + + return ['shops' => $shops, 'bill_items' => $billItems]; + } + + /** @return array{rows:list>,total:int} */ + public function paginate( + ?string $storeName, + ?string $fromDate, + ?string $toDate, + ?string $flowType, + int $page, + int $pageSize, + ): array { + $page = max(1, $page); + $pageSize = min(max(1, $pageSize), 500); + $query = $this->query(); + $this->applyFilters($query, $storeName, $fromDate, $toDate, $flowType); + $total = (clone $query)->count(); + + $rows = $query->orderByDesc('bill_date')->orderByDesc('record_code') + ->forPage($page, $pageSize)->get()->map(function (object $row): array { + $signed = (float) $row->signed_amount; + $income = (float) $row->income_amount; + $expense = (float) $row->expense_amount; + $flowType = $income > 0 ? 1 : ($expense > 0 ? 2 : 0); + + return [ + 'id' => $row->record_code, + 'shopName' => (string) ($row->store_name ?? ''), + 'amount' => abs($signed), + 'balance' => null, + 'summary' => (string) ($row->bill_item ?? ''), + 'flowType' => $flowType, + 'toAdvertiserFundType' => null, + 'createTime' => (string) $row->bill_date, + 'source_primary_key' => (string) ($row->order_no ?? ''), + 'source_record_code' => (string) $row->record_code, + 'source_channel' => 'DewuSettlementBill', + ]; + })->all(); + + return ['rows' => $rows, 'total' => $total]; + } + + /** @return array{income_total:float,expense_total:float,cnt:int} */ + public function summarize( + ?string $storeName, + ?string $fromDate, + ?string $toDate, + ?string $flowType, + ): array { + $query = $this->query(); + $this->applyFilters($query, $storeName, $fromDate, $toDate, $flowType); + $row = $query->selectRaw( + 'COALESCE(SUM(income_amount), 0) AS income_total, ' + . 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS cnt' + )->first(); + + return [ + 'income_total' => (float) ($row->income_total ?? 0), + 'expense_total' => (float) ($row->expense_total ?? 0), + 'cnt' => (int) ($row->cnt ?? 0), + ]; + } + + /** @return array */ + public function paginateAccountBills( + array $filters, + string $sort, + string $direction, + int $page, + int $perPage, + ): array { + $page = max(1, $page); + $perPage = min(max(1, $perPage), 100); + $query = $this->query(); + $this->applyAccountBillFilters($query, $filters); + + $summary = (clone $query)->selectRaw( + 'COALESCE(SUM(income_amount), 0) AS income_total, ' + . 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS row_count' + )->first(); + $total = (int) ($summary->row_count ?? 0); + $income = (float) ($summary->income_total ?? 0); + $expense = (float) ($summary->expense_total ?? 0); + + $sortColumn = $this->accountBillSortColumn($sort); + $direction = $direction === 'asc' ? 'asc' : 'desc'; + + $query->orderBy($sortColumn, $direction); + if ($sortColumn !== 'record_code') { + $query->orderBy('record_code', $direction); + } + + $rows = $query->forPage($page, $perPage)->get() + ->map(fn (object $row): array => $this->mapAccountBillRow($row))->all(); + + return [ + 'data' => $rows, + 'total' => $total, + 'current_page' => $page, + 'last_page' => max(1, (int) ceil($total / $perPage)), + 'per_page' => $perPage, + 'from' => $total === 0 ? null : (($page - 1) * $perPage) + 1, + 'to' => $total === 0 ? null : min($page * $perPage, $total), + 'summary' => [ + 'income' => $income, + 'expense' => $expense, + 'balance' => round($income - $expense, 2), + 'count' => $total, + ], + ]; + } + + /** @return iterable> */ + public function exportAccountBills(array $filters, string $sort, string $direction): iterable + { + $query = $this->query(); + $this->applyAccountBillFilters($query, $filters); + $sortColumn = $this->accountBillSortColumn($sort); + $direction = $direction === 'desc' ? 'desc' : 'asc'; + $query->orderBy($sortColumn, $direction); + if ($sortColumn !== 'record_code') { + $query->orderBy('record_code', $direction); + } + + return $query->cursor()->map(fn (object $row): array => $this->mapAccountBillRow($row)); + } + + private function query(): Builder + { + if (! $this->ready()) { + throw new RuntimeException('dewu_authoritative_bills_not_ready'); + } + + return $this->connection->table(self::ROW_TABLE); + } + + private function applyFilters( + Builder $query, + ?string $storeName, + ?string $fromDate, + ?string $toDate, + ?string $flowType, + ): void { + if ($storeName !== null && $storeName !== '') { + $query->where('store_name', $storeName); + } + $from = $this->date($fromDate); + if ($from !== null) { + $query->where('bill_date', '>=', $from->format('Y-m-d') . ' 00:00:00'); + } + $to = $this->date($toDate); + if ($to !== null) { + $query->where('bill_date', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00'); + } + if ($flowType === 'income') { + $query->where('income_amount', '>', 0); + } elseif ($flowType === 'expense') { + $query->where('expense_amount', '>', 0); + } +} + /** @param array $filters */ + private function applyAccountBillFilters(Builder $query, array $filters): void + { + $storeName = trim((string) ($filters['store_name'] ?? '')); + if ($storeName !== '') { + $query->where('store_name', $storeName); + } + + $billItem = trim((string) ($filters['bill_item'] ?? '')); + if ($billItem !== '') { + $query->where('bill_item', $billItem); + } + + $from = $this->date(isset($filters['start']) ? (string) $filters['start'] : null); + if ($from !== null) { + $query->where('bill_date', '>=', $from->format('Y-m-d') . ' 00:00:00'); + } + + $to = $this->date(isset($filters['end']) ? (string) $filters['end'] : null); + if ($to !== null) { + $query->where('bill_date', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00'); + } + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function (Builder $nested) use ($keyword): void { + $like = '%' . $keyword . '%'; + $nested->where('record_code', 'like', $like) + ->orWhere('order_no', 'like', $like) + ->orWhere('online_order_no', 'like', $like) + ->orWhere('custom_remark', 'like', $like) + ->orWhere('goods_name', 'like', $like) + ->orWhere('goods_no', 'like', $like) + ->orWhere('channel', 'like', $like) + ->orWhere('bill_item', 'like', $like); + }); + } + } + + private function accountBillSortColumn(string $sort): string + { + return [ + 'occurred_at' => 'bill_date', + 'income' => 'income_amount', + 'expense' => 'expense_amount', + 'record_code' => 'record_code', + ][$sort] ?? 'bill_date'; + } + + /** @return array */ + private function mapAccountBillRow(object $row): array + { + $signedAmount = (float) $row->signed_amount; + + return [ + 'id' => (string) $row->record_code, + 'record_code' => (string) $row->record_code, + 'store_name' => (string) ($row->store_name ?? ''), + 'finance_remark' => '', + 'order_no' => (string) ($row->order_no ?? ''), + 'amount' => abs($signedAmount), + 'custom_remark' => (string) ($row->custom_remark ?? ''), + 'bill_item' => (string) ($row->bill_item ?? ''), + 'occurred_at' => $row->paid_refunded_at !== null + ? (string) $row->paid_refunded_at + : null, + 'income' => (float) $row->income_amount, + 'expense' => (float) $row->expense_amount, + 'balance' => $signedAmount, + 'remark' => '', + 'updated_at' => $row->source_updated_at !== null + ? (string) $row->source_updated_at + : null, + 'modifier' => (string) ($row->source_modified_by ?? ''), + 'payload' => [ + 'online_order_no' => (string) ($row->online_order_no ?? ''), + 'store_2' => (string) ($row->store_name ?? ''), + 'order_type' => (string) ($row->order_type ?? ''), + 'goods_name' => (string) ($row->goods_name ?? ''), + 'goods_no' => (string) ($row->goods_no ?? ''), + 'quantity' => (string) ($row->quantity ?? ''), + 'channel' => (string) ($row->channel ?? ''), + 'source_channel' => match ((string) ($row->channel ?? '')) { + '得物' => 'Dewu', + '退货退款订单' => 'DewuTK', + '扣减其他费用明细' => 'DewuKJ', + default => (string) ($row->channel ?? ''), + }, + 'business_type' => (string) ($row->business_type ?? ''), + 'online_real_store' => (string) ($row->online_real_store ?? ''), + 'order_created_at' => $row->order_created_at !== null ? (string) $row->order_created_at : null, + 'ship_return_at' => $row->ship_return_at !== null ? (string) $row->ship_return_at : null, + 'settle_status' => (string) ($row->settle_status ?? ''), + 'pay_period' => (string) ($row->pay_period ?? ''), + 'pulled_at' => $row->pulled_at !== null ? (string) $row->pulled_at : null, + ], + ]; + } + + private function date(?string $value): ?DateTimeImmutable + { + if ($value === null || preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) { + return null; + } + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + + return $date !== false && $date->format('Y-m-d') === $value ? $date : null; + } +} diff --git a/modules/EcommerceFinance/Repositories/DingOnlineDaifaPriceMirrorRepository.php b/modules/EcommerceFinance/Repositories/DingOnlineDaifaPriceMirrorRepository.php index 33a6f88c..c06ab03a 100644 --- a/modules/EcommerceFinance/Repositories/DingOnlineDaifaPriceMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/DingOnlineDaifaPriceMirrorRepository.php @@ -4,16 +4,17 @@ namespace Modules\EcommerceFinance\Repositories; use Illuminate\Database\ConnectionInterface; use Illuminate\Database\Query\Builder; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Services\DingPayableWorkflowStatus; use RuntimeException; /** - * 线上代发业务报销流程的共享镜像读取与按商品ID关联。 + * 线上代发价格的统一镜像查询。 * - * 数据由 DingPayableWorkflowMirrorSynchronizer(ding_online_daifa_price 配置) - * 从钉钉 OA 审批同步到 ding_online_daifa_price_mirror 宽表。本仓库只在镜像库 - * 读取,并依据 商品ID(product_id)关联共享业务镜像的商品目录,页面不直连上游。 + * 钉钉线上代发审批和聚水潭采购单先分别同步到共享业务镜像,再在这里合并。 + * 页面请求只读共享镜像,不跨来源实时查询上游。 */ class DingOnlineDaifaPriceMirrorRepository { @@ -21,8 +22,13 @@ class DingOnlineDaifaPriceMirrorRepository public const TARGET_TABLE = 'ding_online_daifa_price_mirror'; + private const ORDER_DATASET_KEY = 'operations_replica.rpt.jst_Orders'; + private readonly ConnectionInterface $connection; + /** @var array>|null */ + private ?array $sourceStatusCache = null; + public function __construct(GenericBusinessMirrorRepository $mirror) { $this->connection = $mirror->connection(); @@ -30,18 +36,12 @@ class DingOnlineDaifaPriceMirrorRepository public function ready(): bool { - $schema = Schema::connection($this->connection->getName()); - if (! $schema->hasTable(self::TARGET_TABLE) || ! $schema->hasTable('business_mirror_table_states')) { - return false; - } - - $state = $this->connection->table('business_mirror_table_states') - ->where('dataset_key', self::DATASET_KEY)->first(); - - return $state !== null && $state->initial_completed_at !== null; + return collect($this->sourceStatuses())->contains( + static fn (array $source): bool => (bool) ($source['ready'] ?? false), + ); } - /** @return array{data:list>,total:int,status:string,last_synced_at:?string,summary:array} */ + /** @return array{data:list>,total:int,status:string,last_synced_at:?string,summary:array,source_statuses:array>} */ public function paginate(array $filters, int $page, int $perPage): array { if (! $this->ready()) { @@ -50,38 +50,37 @@ class DingOnlineDaifaPriceMirrorRepository $page = max($page, 1); $perPage = min(max($perPage, 1), 100); - $state = $this->connection->table('business_mirror_table_states')->where('dataset_key', self::DATASET_KEY)->first(); - - // The synchronized dataset normally contains one configured status, so - // count, totals, and paging can stay in SQL instead of loading all rows. - $onlyStatus = strtoupper(trim((string) config('upstream_mirrors.ding_online_daifa_price.only_status', ''))); - $statusFilter = strtoupper(trim((string) ($filters['status'] ?? ''))); - $statusMismatch = $statusFilter !== '' && $onlyStatus !== '' && $statusFilter !== $onlyStatus; - - $total = $statusMismatch ? 0 : (int) $this->filteredQuery($filters)->count(); + $total = (int) $this->filteredQuery($filters)->count(); $summary = [ 'count' => 0, 'matched_count' => 0, + 'matchable_count' => 0, + 'purchase_count' => 0, 'daifa_amount' => 0.0, 'paid_amount' => 0.0, 'price_diff' => 0.0, ]; $pageRows = []; - if (! $statusMismatch) { + if ($total > 0) { $aggregate = $this->filteredQuery($filters) ->selectRaw( 'COUNT(*) AS row_count, ' - . "SUM(CASE WHEN product_id IS NOT NULL AND product_id <> '' AND product_name IS NOT NULL AND product_name <> '' THEN 1 ELSE 0 END) AS matched_count, " - . 'COALESCE(SUM(daifa_amount), 0) AS daifa_amount, ' - . 'COALESCE(SUM(paid_amount), 0) AS paid_amount, ' - . 'COALESCE(SUM(price_diff), 0) AS price_diff' + . "SUM(CASE WHEN source_type = 'purchase' THEN 1 ELSE 0 END) AS purchase_count, " + . "SUM(CASE WHEN source_type = 'ding' THEN 1 ELSE 0 END) AS matchable_count, " + . "SUM(CASE WHEN source_type = 'ding' AND product_id IS NOT NULL AND product_id <> '' " + . "AND product_name IS NOT NULL AND product_name <> '' THEN 1 ELSE 0 END) AS matched_count, " + . "COALESCE(SUM(CASE WHEN source_type = 'ding' THEN daifa_amount ELSE 0 END), 0) AS daifa_amount, " + . "COALESCE(SUM(CASE WHEN source_type = 'ding' THEN paid_amount ELSE 0 END), 0) AS paid_amount, " + . "COALESCE(SUM(CASE WHEN source_type = 'ding' THEN price_diff ELSE 0 END), 0) AS price_diff" ) ->first(); $summary = [ 'count' => (int) ($aggregate->row_count ?? 0), 'matched_count' => (int) ($aggregate->matched_count ?? 0), + 'matchable_count' => (int) ($aggregate->matchable_count ?? 0), + 'purchase_count' => (int) ($aggregate->purchase_count ?? 0), 'daifa_amount' => round((float) ($aggregate->daifa_amount ?? 0), 2), 'paid_amount' => round((float) ($aggregate->paid_amount ?? 0), 2), 'price_diff' => round((float) ($aggregate->price_diff ?? 0), 2), @@ -97,16 +96,22 @@ class DingOnlineDaifaPriceMirrorRepository $data = []; foreach ($pageRows as $row) { - $workflow = $this->decode((string) $row->workflow_payload, []); - $data[] = $this->presentRow($row, $workflow); + $data[] = $this->presentRow($row); } + $sources = $this->sourceStatuses(); + $readySources = array_filter($sources, static fn (array $source): bool => (bool) ($source['ready'] ?? false)); + $synced = array_values(array_filter(array_column($readySources, 'last_synced_at'))); + return [ 'data' => $data, 'total' => $total, - 'status' => (string) ($state->status ?? 'unknown'), - 'last_synced_at' => $state->last_succeeded_at ? (string) $state->last_succeeded_at : null, + 'status' => count($readySources) > 1 + ? 'available' + : (string) (reset($readySources)['status'] ?? 'unknown'), + 'last_synced_at' => $synced === [] ? null : max($synced), 'summary' => $summary, + 'source_statuses' => $sources, ]; } @@ -117,7 +122,6 @@ class DingOnlineDaifaPriceMirrorRepository throw new RuntimeException('business_mirror_dataset_not_ready'); } - $statusFilter = strtoupper(trim((string) ($filters['status'] ?? ''))); $rows = $this->filteredQuery($filters) ->orderByDesc('workflow_created_at') ->orderByDesc('process_instance_id') @@ -125,18 +129,13 @@ class DingOnlineDaifaPriceMirrorRepository ->cursor(); foreach ($rows as $row) { - $workflow = $this->decode((string) $row->workflow_payload, []); - if (! $this->matchesStatus($workflow, $statusFilter)) { - continue; - } - - yield $this->presentRow($row, $workflow); + yield $this->presentRow($row); } } private function filteredQuery(array $filters): Builder { - $query = $this->connection->table(self::TARGET_TABLE); + $query = $this->combinedQuery(); if (($filters['product_id'] ?? '') !== '') { $query->where('product_id', (string) $filters['product_id']); @@ -155,58 +154,236 @@ class DingOnlineDaifaPriceMirrorRepository $query->where(function ($q) use ($keyword): void { $q->where('process_instance_id', 'like', '%' . $keyword . '%') ->orWhere('daifa_trade_no', 'like', '%' . $keyword . '%') - ->orWhere('shop_trade_no', 'like', '%' . $keyword . '%'); + ->orWhere('shop_trade_no', 'like', '%' . $keyword . '%') + ->orWhere('source_order_no', 'like', '%' . $keyword . '%'); }); } + if (($filters['status'] ?? '') !== '') { + $query->where('status_key', strtoupper((string) $filters['status'])); + } if (($filters['created_from'] ?? '') !== '') { $query->where('workflow_created_at', '>=', (string) $filters['created_from']); } if (($filters['created_to'] ?? '') !== '') { - $query->where('workflow_created_at', '<', \Illuminate\Support\Carbon::parse((string) $filters['created_to'])->addDay()->startOfDay()->format('Y-m-d H:i:s')); + $query->where( + 'workflow_created_at', + '<', + Carbon::parse((string) $filters['created_to'])->addDay()->startOfDay()->format('Y-m-d H:i:s'), + ); } return $query; } - private function matchesStatus(array $workflow, string $statusFilter): bool + private function combinedQuery(): Builder { - return $statusFilter === '' || DingPayableWorkflowStatus::key($workflow) === $statusFilter; + $statuses = $this->sourceStatuses(); + $queries = []; + + if ((bool) ($statuses['ding']['ready'] ?? false)) { + $queries[] = $this->dingQuery(); + } + if ((bool) ($statuses['purchase']['ready'] ?? false)) { + $queries[] = $this->purchaseQuery(); + } + + if ($queries === []) { + throw new RuntimeException('business_mirror_dataset_not_ready'); + } + + $union = array_shift($queries); + foreach ($queries as $query) { + $union->unionAll($query); + } + + return $this->connection->query()->fromSub($union, 'online_daifa_rows'); + } + + private function dingQuery(): Builder + { + $status = strtoupper(trim((string) config( + 'upstream_mirrors.ding_online_daifa_price.only_status', + 'COMPLETED', + ))) ?: 'COMPLETED'; + + return $this->connection->table(self::TARGET_TABLE . ' as d')->select([ + DB::raw("'ding' AS source_type"), + 'd.process_instance_id', + 'd.detail_index', + DB::raw('NULL AS purchase_order_id'), + DB::raw('NULL AS source_order_no'), + 'd.product_id', + 'd.product_name', + 'd.daifa_trade_no', + 'd.shop_trade_no', + 'd.shop_name', + 'd.payee_name', + DB::raw("CASE WHEN d.payee_name = '宁波顾家望业家居有限公司' THEN '刘萧代发' ELSE '1688代发' END AS tag"), + 'd.supplier_name', + 'd.material_no', + 'd.ship_qty', + 'd.paid_amount', + 'd.daifa_amount', + 'd.price_diff', + 'd.workflow_payload', + 'd.scheme_payload', + 'd.task_payload', + DB::raw("'" . $status . "' AS status_key"), + 'd.workflow_created_at', + ]); + } + + private function purchaseQuery(): Builder + { + return $this->connection->table('product_purchase_price_mirror_rows as p') + ->leftJoin('ops_jst_orders as o', 'o.o_id', '=', 'p.source_order_no') + ->whereIn('p.document_status', ['Confirmed', 'Finished']) + ->select([ + DB::raw("'purchase' AS source_type"), + 'p.purchase_order_id as process_instance_id', + 'p.item_id as detail_index', + 'p.purchase_order_id', + 'p.source_order_no', + DB::raw("'' AS product_id"), + DB::raw('NULL AS product_name'), + DB::raw("'' AS daifa_trade_no"), + 'o.so_id as shop_trade_no', + 'o.shop_name', + DB::raw("'' AS payee_name"), + DB::raw("'采购单管理' AS tag"), + 'p.supplier_name', + 'p.sku_id as material_no', + 'p.quantity as ship_qty', + DB::raw('NULL AS paid_amount'), + 'p.unit_price as daifa_amount', + DB::raw('NULL AS price_diff'), + DB::raw('NULL AS workflow_payload'), + DB::raw('NULL AS scheme_payload'), + DB::raw('NULL AS task_payload'), + DB::raw('UPPER(p.document_status) AS status_key'), + DB::raw('COALESCE(p.document_created_at, p.purchase_updated_at) AS workflow_created_at'), + ]); + } + + /** @return array> */ + private function sourceStatuses(): array + { + if ($this->sourceStatusCache !== null) { + return $this->sourceStatusCache; + } + + $schema = Schema::connection($this->connection->getName()); + $dingState = null; + if ($schema->hasTable(self::TARGET_TABLE) && $schema->hasTable('business_mirror_table_states')) { + $dingState = $this->connection->table('business_mirror_table_states') + ->where('dataset_key', self::DATASET_KEY) + ->first(); + } + $dingReady = $dingState !== null && $dingState->initial_completed_at !== null; + + $purchaseState = null; + $orderState = null; + $requiredPurchaseColumns = [ + 'source_order_no', 'supplier_name', 'quantity', 'document_created_at', + ]; + $purchaseSchemaReady = $schema->hasTable('product_purchase_price_mirror_rows') + && $schema->hasTable('product_purchase_price_sync_states') + && $schema->hasTable('ops_jst_orders') + && $schema->hasColumns('product_purchase_price_mirror_rows', $requiredPurchaseColumns); + + if ($purchaseSchemaReady) { + $purchaseState = $this->connection->table('product_purchase_price_sync_states') + ->where('dataset_key', ProductPurchasePriceMirrorRepository::DATASET_KEY) + ->first(); + $orderState = $schema->hasTable('business_mirror_table_states') + ? $this->connection->table('business_mirror_table_states') + ->where('dataset_key', self::ORDER_DATASET_KEY) + ->first() + : null; + } + + $purchaseReady = $purchaseState !== null + && $purchaseState->initial_completed_at !== null + && (int) ($purchaseState->projection_version ?? 1) + >= ProductPurchasePriceMirrorRepository::CURRENT_PROJECTION_VERSION + && $orderState !== null + && $orderState->initial_completed_at !== null; + + return $this->sourceStatusCache = [ + 'ding' => [ + 'label' => '钉钉线上代发审批', + 'ready' => $dingReady, + 'status' => $dingState === null ? 'pending' : (string) $dingState->status, + 'last_synced_at' => $dingState?->last_succeeded_at === null + ? null + : (string) $dingState->last_succeeded_at, + ], + 'purchase' => [ + 'label' => '聚水潭采购单管理', + 'ready' => $purchaseReady, + 'status' => $purchaseReady + ? (string) $purchaseState->status + : ($purchaseSchemaReady ? 'requires_full_sync' : 'pending'), + 'last_synced_at' => $purchaseState?->last_succeeded_at === null + ? null + : (string) $purchaseState->last_succeeded_at, + ], + ]; } /** @return array */ - private function presentRow(object $row, array $workflow): array + private function presentRow(object $row): array { + $sourceType = (string) ($row->source_type ?? 'ding'); + $purchase = $sourceType === 'purchase'; + $workflow = $purchase + ? [ + 'businessId' => (string) ($row->purchase_order_id ?? ''), + 'title' => '采购单管理', + 'status' => (string) ($row->status_key ?? ''), + ] + : $this->decode((string) ($row->workflow_payload ?? ''), []); $productId = (string) ($row->product_id ?? ''); - // 商品名称已在同步阶段按 商品ID 关联商品目录并写入镜像宽表 product_name 列, - // 页面直接读取,避免对 2500 万行的 ops_jst_sku_map 做临时 JOIN 导致超时。 $resolvedName = $this->cleanName($row->product_name ?? null); - $product = $productId !== '' && $resolvedName !== null + $product = ! $purchase && $productId !== '' && $resolvedName !== null ? ['matched' => true, 'name' => $resolvedName, 'sku_id' => null, 'i_id' => null] : ['matched' => false, 'name' => null, 'sku_id' => null, 'i_id' => null]; + $statusKey = $purchase + ? strtoupper((string) ($row->status_key ?? '')) + : DingPayableWorkflowStatus::key($workflow); return [ + 'source_type' => $sourceType, 'process_instance_id' => (string) $row->process_instance_id, 'detail_index' => (int) $row->detail_index, + 'purchase_order_id' => $row->purchase_order_id === null ? null : (int) $row->purchase_order_id, + 'source_order_no' => $row->source_order_no === null ? null : (string) $row->source_order_no, 'product_id' => $productId, 'daifa_trade_no' => (string) ($row->daifa_trade_no ?? ''), 'shop_trade_no' => (string) ($row->shop_trade_no ?? ''), 'shop_name' => (string) ($row->shop_name ?? ''), 'payee_name' => (string) ($row->payee_name ?? ''), - 'tag' => ((string) ($row->payee_name ?? '')) === '宁波顾家望业家居有限公司' - ? '刘萧代发' - : '1688代发', + 'tag' => (string) ($row->tag ?? ''), 'supplier_name' => (string) ($row->supplier_name ?? ''), 'material_no' => (string) ($row->material_no ?? ''), - 'ship_qty' => $row->ship_qty === null ? null : (int) $row->ship_qty, + 'ship_qty' => $row->ship_qty === null ? null : (float) $row->ship_qty, 'paid_amount' => $row->paid_amount === null ? null : (float) $row->paid_amount, 'daifa_amount' => $row->daifa_amount === null ? null : (float) $row->daifa_amount, 'price_diff' => $row->price_diff === null ? null : (float) $row->price_diff, 'workflow' => $workflow, - 'scheme' => $this->decode((string) ($row->scheme_payload ?? '[]'), []), - 'tasks' => $this->decode((string) ($row->task_payload ?? '[]'), []), - 'status_key' => DingPayableWorkflowStatus::key($workflow), - 'status_label' => DingPayableWorkflowStatus::label($workflow), + 'scheme' => $purchase ? [] : $this->decode((string) ($row->scheme_payload ?? ''), []), + 'tasks' => $purchase ? [] : $this->decode((string) ($row->task_payload ?? ''), []), + 'status_key' => $statusKey, + 'status_label' => $purchase + ? match ($statusKey) { + 'CONFIRMED' => '已确认', + 'FINISHED' => '已完成', + default => $statusKey, + } + : DingPayableWorkflowStatus::label($workflow), 'matched_product' => $product, + 'order_matched' => ! $purchase + || ((string) ($row->shop_trade_no ?? '') !== '' && (string) ($row->shop_name ?? '') !== ''), 'workflow_created_at' => (string) ($row->workflow_created_at ?? ''), ]; } @@ -222,6 +399,7 @@ class DingOnlineDaifaPriceMirrorRepository return null; } $name = trim((string) $value); + return $name === '' ? null : $name; } @@ -231,6 +409,7 @@ class DingOnlineDaifaPriceMirrorRepository return $fallback; } $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : $fallback; } } diff --git a/modules/EcommerceFinance/Repositories/PinduoduoSettlementBillRepository.php b/modules/EcommerceFinance/Repositories/PinduoduoSettlementBillRepository.php new file mode 100644 index 00000000..b3922e20 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/PinduoduoSettlementBillRepository.php @@ -0,0 +1,304 @@ +connection = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + public function ready(): bool + { + $schema = Schema::connection($this->connection->getName()); + if (! $schema->hasTable(self::IMPORT_TABLE) || ! $schema->hasTable(self::ROW_TABLE)) { + return false; + } + + return $this->connection->table(self::IMPORT_TABLE)->where('status', 'succeeded')->exists(); + } + + /** @return string[] */ + public function storeOptions(): array + { + return $this->query()->whereNotNull('store_name')->where('store_name', '<>', '') + ->distinct()->orderBy('store_name')->pluck('store_name')->map('strval')->all(); + } + + /** @return array{shops:list,bill_items:list} */ + public function accountBillFilterOptions(): array + { + $shops = $this->query()->whereNotNull('store_name')->where('store_name', '<>', '') + ->distinct()->orderBy('store_name')->pluck('store_name') + ->map(static fn (mixed $value): string => (string) $value)->all(); + $billItems = $this->query()->whereNotNull('bill_item')->where('bill_item', '<>', '') + ->distinct()->orderBy('bill_item')->pluck('bill_item') + ->map(static fn (mixed $value): string => (string) $value)->all(); + + return ['shops' => $shops, 'bill_items' => $billItems]; + } + + /** @return array{rows:list>,total:int} */ + public function paginate( + ?string $storeName, + ?string $fromDate, + ?string $toDate, + ?string $flowType, + int $page, + int $pageSize, + ): array { + $page = max(1, $page); + $pageSize = min(max(1, $pageSize), 500); + $query = $this->query(); + $this->applyFilters($query, $storeName, $fromDate, $toDate, $flowType); + $total = (clone $query)->count(); + + $rows = $query->orderByDesc('occurred_at')->orderByDesc('record_code') + ->forPage($page, $pageSize)->get()->map(function (object $row): array { + $signed = (float) $row->signed_amount; + $income = (float) $row->income_amount; + $expense = (float) $row->expense_amount; + $flowType = $income > 0 ? 1 : ($expense > 0 ? 2 : 0); + + return [ + 'id' => $row->record_code, + 'shopName' => (string) ($row->store_name ?? ''), + 'amount' => abs($signed), + 'balance' => null, + 'summary' => (string) ($row->bill_item ?? ''), + 'flowType' => $flowType, + 'toAdvertiserFundType' => null, + 'createTime' => (string) $row->occurred_at, + 'source_primary_key' => (string) ($row->merchant_order_no ?? ''), + 'source_record_code' => (string) $row->record_code, + 'source_channel' => 'PinduoduoSettlementBill', + ]; + })->all(); + + return ['rows' => $rows, 'total' => $total]; + } + + /** @return array{income_total:float,expense_total:float,cnt:int} */ + public function summarize( + ?string $storeName, + ?string $fromDate, + ?string $toDate, + ?string $flowType, + ): array { + $query = $this->query(); + $this->applyFilters($query, $storeName, $fromDate, $toDate, $flowType); + $row = $query->selectRaw( + 'COALESCE(SUM(income_amount), 0) AS income_total, ' + . 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS cnt' + )->first(); + + return [ + 'income_total' => (float) ($row->income_total ?? 0), + 'expense_total' => (float) ($row->expense_total ?? 0), + 'cnt' => (int) ($row->cnt ?? 0), + ]; + } + + /** @return array */ + public function paginateAccountBills( + array $filters, + string $sort, + string $direction, + int $page, + int $perPage, + ): array { + $page = max(1, $page); + $perPage = min(max(1, $perPage), 100); + $query = $this->query(); + $this->applyAccountBillFilters($query, $filters); + + $summary = (clone $query)->selectRaw( + 'COALESCE(SUM(income_amount), 0) AS income_total, ' + . 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS row_count' + )->first(); + $total = (int) ($summary->row_count ?? 0); + $income = (float) ($summary->income_total ?? 0); + $expense = (float) ($summary->expense_total ?? 0); + + $sortColumn = $this->accountBillSortColumn($sort); + $direction = $direction === 'asc' ? 'asc' : 'desc'; + + $query->orderBy($sortColumn, $direction); + if ($sortColumn !== 'record_code') { + $query->orderBy('record_code', $direction); + } + + $rows = $query->forPage($page, $perPage)->get() + ->map(fn (object $row): array => $this->mapAccountBillRow($row))->all(); + + return [ + 'data' => $rows, + 'total' => $total, + 'current_page' => $page, + 'last_page' => max(1, (int) ceil($total / $perPage)), + 'per_page' => $perPage, + 'from' => $total === 0 ? null : (($page - 1) * $perPage) + 1, + 'to' => $total === 0 ? null : min($page * $perPage, $total), + 'summary' => [ + 'income' => $income, + 'expense' => $expense, + 'balance' => round($income - $expense, 2), + 'count' => $total, + ], + ]; + } + + /** @return iterable> */ + public function exportAccountBills(array $filters, string $sort, string $direction): iterable + { + $query = $this->query(); + $this->applyAccountBillFilters($query, $filters); + $sortColumn = $this->accountBillSortColumn($sort); + $direction = $direction === 'desc' ? 'desc' : 'asc'; + $query->orderBy($sortColumn, $direction); + if ($sortColumn !== 'record_code') { + $query->orderBy('record_code', $direction); + } + + return $query->cursor()->map(fn (object $row): array => $this->mapAccountBillRow($row)); + } + + private function query(): Builder + { + if (! $this->ready()) { + throw new RuntimeException('pinduoduo_authoritative_bills_not_ready'); + } + + return $this->connection->table(self::ROW_TABLE); + } + + private function applyFilters( + Builder $query, + ?string $storeName, + ?string $fromDate, + ?string $toDate, + ?string $flowType, + ): void { + if ($storeName !== null && $storeName !== '') { + $query->where('store_name', $storeName); + } + $from = $this->date($fromDate); + if ($from !== null) { + $query->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00'); + } + $to = $this->date($toDate); + if ($to !== null) { + $query->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00'); + } + if ($flowType === 'income') { + $query->where('income_amount', '>', 0); + } elseif ($flowType === 'expense') { + $query->where('expense_amount', '>', 0); + } +} + /** @param array $filters */ + private function applyAccountBillFilters(Builder $query, array $filters): void + { + $storeName = trim((string) ($filters['store_name'] ?? '')); + if ($storeName !== '') { + $query->where('store_name', $storeName); + } + + $billItem = trim((string) ($filters['bill_item'] ?? '')); + if ($billItem !== '') { + $query->where('bill_item', $billItem); + } + + $from = $this->date(isset($filters['start']) ? (string) $filters['start'] : null); + if ($from !== null) { + $query->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00'); + } + + $to = $this->date(isset($filters['end']) ? (string) $filters['end'] : null); + if ($to !== null) { + $query->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00'); + } + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function (Builder $nested) use ($keyword): void { + $like = '%' . $keyword . '%'; + $nested->where('record_code', 'like', $like) + ->orWhere('merchant_order_no', 'like', $like) + ->orWhere('online_order_no', 'like', $like) + ->orWhere('remark', 'like', $like) + ->orWhere('custom_remark', 'like', $like) + ->orWhere('business_description', 'like', $like) + ->orWhere('bill_item', 'like', $like); + }); + } + } + + private function accountBillSortColumn(string $sort): string + { + return [ + 'occurred_at' => 'occurred_at', + 'income' => 'income_amount', + 'expense' => 'expense_amount', + 'record_code' => 'record_code', + ][$sort] ?? 'occurred_at'; + } + + /** @return array */ + private function mapAccountBillRow(object $row): array + { + $signedAmount = (float) $row->signed_amount; + + return [ + 'id' => (string) $row->record_code, + 'record_code' => (string) $row->record_code, + 'store_name' => (string) ($row->store_name ?? ''), + 'finance_remark' => (string) ($row->business_description ?? ''), + 'order_no' => (string) ($row->merchant_order_no ?? ''), + 'amount' => abs($signedAmount), + 'custom_remark' => (string) ($row->custom_remark ?? ''), + 'bill_item' => (string) ($row->bill_item ?? ''), + 'occurred_at' => (string) $row->occurred_at, + 'income' => (float) $row->income_amount, + 'expense' => (float) $row->expense_amount, + 'balance' => $signedAmount, + 'remark' => (string) ($row->remark ?? ''), + 'updated_at' => $row->source_updated_at !== null + ? (string) $row->source_updated_at + : null, + 'modifier' => (string) ($row->source_modified_by ?? ''), + 'payload' => [ + 'merchant_order_no' => (string) ($row->merchant_order_no ?? ''), + 'online_order_no' => (string) ($row->online_order_no ?? ''), + 'finance_type' => (string) ($row->business_type ?? ''), + 'business_description' => (string) ($row->business_description ?? ''), + 'type' => (string) ($row->account_name ?? ''), + ], + ]; + } + + private function date(?string $value): ?DateTimeImmutable + { + if ($value === null || preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) { + return null; + } + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + + return $date !== false && $date->format('Y-m-d') === $value ? $date : null; + } +} diff --git a/modules/EcommerceFinance/Repositories/ProductPurchasePriceMirrorRepository.php b/modules/EcommerceFinance/Repositories/ProductPurchasePriceMirrorRepository.php index 1c5ff984..fc73ff97 100644 --- a/modules/EcommerceFinance/Repositories/ProductPurchasePriceMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ProductPurchasePriceMirrorRepository.php @@ -12,6 +12,8 @@ class ProductPurchasePriceMirrorRepository { public const DATASET_KEY = 'product-inventory.purchase-order-prices'; + public const CURRENT_PROJECTION_VERSION = 2; + private readonly ConnectionInterface $connection; public function __construct(DatabaseManager $database) @@ -33,7 +35,9 @@ class ProductPurchasePriceMirrorRepository { $state = $this->state(); - return is_array($state) && ($state['initial_completed_at'] ?? null) !== null; + return is_array($state) + && ($state['initial_completed_at'] ?? null) !== null + && (int) ($state['projection_version'] ?? 1) >= self::CURRENT_PROJECTION_VERSION; } /** @return array|null */ @@ -67,6 +71,7 @@ class ProductPurchasePriceMirrorRepository 'status' => 'running', 'run_mode' => $mode, 'generation' => $generation, + 'projection_version' => self::CURRENT_PROJECTION_VERSION, 'last_attempted_at' => $now, 'last_error_code' => null, 'updated_at' => $now, @@ -104,11 +109,15 @@ class ProductPurchasePriceMirrorRepository $values = [ 'item_id' => (int) $row['item_id'], 'purchase_order_id' => (int) $row['purchase_order_id'], + 'source_order_no' => $this->nullablePositiveInteger($row['source_order_no'] ?? null), + 'supplier_name' => $this->nullableString($row['supplier_name'] ?? null), 'sku_id' => $this->nullableString($row['sku_id'] ?? null), 'style_id' => $this->nullableString($row['style_id'] ?? null), + 'quantity' => $row['quantity'] ?? null, 'unit_price' => $row['unit_price'] ?? null, 'document_status' => $this->nullableString($row['document_status'] ?? null), 'labels' => $this->nullableString($row['labels'] ?? null), + 'document_created_at' => $row['document_created_at'] ?? null, 'finished_at' => $row['finished_at'] ?? null, 'purchase_updated_at' => $row['purchase_updated_at'], ]; @@ -130,8 +139,9 @@ class ProductPurchasePriceMirrorRepository $mapped, ['item_id'], [ - 'purchase_order_id', 'sku_id', 'style_id', 'unit_price', - 'document_status', 'labels', 'finished_at', 'purchase_updated_at', + 'purchase_order_id', 'source_order_no', 'supplier_name', + 'sku_id', 'style_id', 'quantity', 'unit_price', + 'document_status', 'labels', 'document_created_at', 'finished_at', 'purchase_updated_at', '_mirror_generation', 'row_hash', '_mirror_synced_at', 'updated_at', ], ); @@ -211,4 +221,15 @@ class ProductPurchasePriceMirrorRepository return $value === '' ? null : $value; } + + private function nullablePositiveInteger(mixed $value): ?int + { + if ($value === null || $value === '' || ! is_numeric($value)) { + return null; + } + + $value = (int) $value; + + return $value > 0 ? $value : null; + } } diff --git a/modules/EcommerceFinance/Repositories/ProductPurchasePriceRepository.php b/modules/EcommerceFinance/Repositories/ProductPurchasePriceRepository.php index 713674d6..b297a5a7 100644 --- a/modules/EcommerceFinance/Repositories/ProductPurchasePriceRepository.php +++ b/modules/EcommerceFinance/Repositories/ProductPurchasePriceRepository.php @@ -34,8 +34,11 @@ class ProductPurchasePriceRepository $sql = 'SELECT TOP (' . $limit . ') ' . 'i.[Id] AS item_id, p.[po_id] AS purchase_order_id, ' - . 'i.[sku_id] AS sku_id, i.[i_id] AS style_id, i.[price] AS unit_price, ' + . 'CONVERT(varchar(64), CONVERT(decimal(38, 0), p.[source_o_id])) AS source_order_no, ' + . 'p.[seller] AS supplier_name, ' + . 'i.[sku_id] AS sku_id, i.[i_id] AS style_id, i.[qty] AS quantity, i.[price] AS unit_price, ' . 'p.[status] AS document_status, p.[labels] AS labels, ' + . 'CONVERT(varchar(27), p.[createTime], 126) AS document_created_at, ' . 'CONVERT(varchar(27), p.[finish_time], 126) AS finished_at, ' . 'CONVERT(varchar(27), p.[updateTime], 126) AS purchase_updated_at ' . 'FROM [dbo].[jst_purchase] p ' diff --git a/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php b/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php new file mode 100644 index 00000000..e2605f67 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php @@ -0,0 +1,134 @@ +db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local')); + } + + /** + * @param array $filters + * @return array{data:array,total:int} + */ + public function paginate(array $filters, int $page, int $perPage): array + { + $page = max($page, 1); + $perPage = min(max($perPage, 1), 100); + + $q = $this->filteredQuery($filters); + + $total = (clone $q)->count(); + $rows = (clone $q) + ->orderByDesc('ship_time') + ->orderByDesc('id') + ->forPage($page, $perPage) + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + + return ['data' => $rows, 'total' => $total]; + } + + /** + * @param array $filters + * @return \Generator> + */ + public function exportRows(array $filters, int $batchSize = 1000): \Generator + { + $batchSize = min(max($batchSize, 1), 5000); + $lastId = null; + + while (true) { + $query = $this->filteredQuery($filters); + if ($lastId !== null) { + $query->where('id', '>', $lastId); + } + + $rows = $query->orderBy('id')->limit($batchSize)->get()->all(); + if ($rows === []) { + return; + } + + foreach ($rows as $row) { + yield (array) $row; + } + + $lastId = (int) $rows[array_key_last($rows)]->id; + if (count($rows) < $batchSize) { + return; + } + } + } + + /** @param array $filters */ + private function filteredQuery(array $filters): Builder + { + $query = $this->db->table('jst_oms_shipping'); + + if (($filters['express_no'] ?? '') !== '') { + $query->where('express_no', '=', trim((string) $filters['express_no'])); + } + if (($filters['sourceno'] ?? '') !== '') { + $query->where('trade_no', '=', trim((string) $filters['sourceno'])); + } + + foreach (['barcode', 'supplier_name', 'shop_name', 'warehouse_name'] as $column) { + if (($filters[$column] ?? '') !== '') { + $query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains((string) $filters[$column])]); + } + } + + if (($filters['express_company'] ?? '') !== '') { + $value = $this->contains((string) $filters['express_company']); + $query->where(static function (Builder $courier) use ($value): void { + $courier->whereRaw("express_name1 LIKE ? ESCAPE '!'", [$value]) + ->orWhereRaw("express_name2 LIKE ? ESCAPE '!'", [$value]); + }); + } + + if (($filters['ship_from'] ?? '') !== '') { + $query->where('ship_time', '>=', $filters['ship_from'] . ' 00:00:00'); + } + if (($filters['ship_to'] ?? '') !== '') { + $query->where('ship_time', '<', date('Y-m-d 00:00:00', strtotime($filters['ship_to'] . ' +1 day'))); + } + + return $query; + } + + private function contains(string $value): string + { + return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%'; + } + + /** 同步状态(供页面显示同步中/最近完成)。 */ + public function syncStatus(): array + { + try { + $row = $this->db->table('jst_sync_state')->where('dataset_key', 'jst.sctrade')->first(); + if (! $row) { + return ['code' => 'unavailable', 'last_succeeded_at' => null]; + } + + return [ + 'code' => (string) ($row->status ?? 'unavailable'), + 'last_succeeded_at' => $row->last_succeeded_at ?? null, + ]; + } catch (\Throwable) { + return ['code' => 'unavailable', 'last_succeeded_at' => null]; + } + } +} diff --git a/modules/EcommerceFinance/Repositories/SupplierPayableRepository.php b/modules/EcommerceFinance/Repositories/SupplierPayableRepository.php index 96dfc4f1..7f3130d4 100644 --- a/modules/EcommerceFinance/Repositories/SupplierPayableRepository.php +++ b/modules/EcommerceFinance/Repositories/SupplierPayableRepository.php @@ -135,6 +135,7 @@ class SupplierPayableRepository $whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; $sql = "SELECT + id AS id, CONVERT(varchar(10), date, 120) AS send_time, supplierName AS supplier, sku_id AS barcode, @@ -225,6 +226,7 @@ class SupplierPayableRepository $whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; $sql = "SELECT + id AS id, CONVERT(varchar(7), month, 120) AS month_str, '' AS category, supplier AS payee, @@ -281,6 +283,7 @@ class SupplierPayableRepository $rows = $this->query->select( "SELECT + [id] AS id, [Month] AS month, [supplier] AS supplier, CAST([Totalindemnity] AS decimal(18,2)) AS amount, @@ -369,6 +372,7 @@ class SupplierPayableRepository $whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; $sql = "SELECT + id AS id, supplier, '' AS contract_name, CAST(price AS decimal(18,2)) AS advance_amount, @@ -454,6 +458,7 @@ class SupplierPayableRepository $whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; $sql = "SELECT + id AS id, CONVERT(varchar(10), finishTime, 120) AS finish_time, department, creator, @@ -619,6 +624,7 @@ class SupplierPayableRepository $whereSql = 'WHERE ' . implode(' AND ', $where); $sql = "SELECT + id AS id, CONVERT(varchar(10), date, 120) AS send_time, supplierName AS supplier, sku_id AS barcode, @@ -759,7 +765,7 @@ class SupplierPayableRepository $whereSql = 'WHERE ' . implode(' AND ', $where); $sql = "SELECT - '其他扣款补款' AS project, + id AS id, Month AS month_str, supplier, CAST(other AS decimal(18,2)) AS supplement_amount, @@ -770,7 +776,13 @@ class SupplierPayableRepository FROM fys_supplierPaymentSummary $whereSql"; - return $this->runQuery($sql, $params, $page, $pageSize, 'month_str DESC, supplier'); + $result = $this->runQuery($sql, $params, $page, $pageSize, 'month_str DESC, supplier'); + foreach ($result['data'] as &$row) { + $row['project'] = '其他扣款补款'; + } + unset($row); + + return $result; } public function otherAdjustmentsSummary(array $filters): array @@ -825,6 +837,7 @@ class SupplierPayableRepository $whereSql = 'WHERE ' . implode(' AND ', $where); $sql = "SELECT + id AS id, reconciliation_person, month AS month_str, courier_company_name AS supplier, diff --git a/modules/EcommerceFinance/Services/DewuSettlementBillImporter.php b/modules/EcommerceFinance/Services/DewuSettlementBillImporter.php new file mode 100644 index 00000000..9b3a12be --- /dev/null +++ b/modules/EcommerceFinance/Services/DewuSettlementBillImporter.php @@ -0,0 +1,731 @@ + ['记录编码'], + 'order_no' => ['订单号'], + 'online_order_no' => ['线上单号'], + 'custom_remark' => ['自定义备注'], + 'bill_item' => ['账单项目'], + 'store_name' => ['店铺'], + 'order_type' => ['订单类型'], + 'goods_name' => ['商品名称'], + 'goods_no' => ['商品货号'], + 'quantity' => ['数量'], + 'channel' => ['渠道'], + 'business_type' => ['业务类型'], + 'income_amount' => ['收入金额'], + 'expense_amount' => ['支出金额'], + 'online_real_store' => ['线上单号对应实际店铺'], + 'order_created_at' => ['订单创建时间'], + 'paid_refunded_at' => ['支付/退款时间'], + 'ship_return_at' => ['发货/退货时间'], + 'settle_status' => ['结算状态'], + 'pay_period' => ['收款账期'], + 'pulled_at' => ['拉取时间'], + 'source_updated_at' => ['修改时间'], + 'source_modified_by' => ['修改人'], + ]; + + private readonly ConnectionInterface $connection; + + public function __construct(DatabaseManager $databases) + { + $this->connection = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + /** + * @return array{status:string,row_count:int,excluded_rows:int,store_count:int,bill_item_count:int,channel_counts:array,income_total:string,expense_total:string,net_total:string,period_start:string,period_end:string,source_sha256:string} + */ + public function import( + string $path, + string $fromDate, + string $toDate, + int $expectedCount, + string $expectedIncome, + string $expectedExpense, + int $batchSize = 1000, + ): array { + $path = $this->validatedPath($path); + [$from, $to] = $this->period($fromDate, $toDate); + $this->assertSchemaReady(); + if ($expectedCount <= 0) { + throw new RuntimeException('权威笔数控制数必须大于零。'); + } + $expectedIncome = $this->controlAmount($expectedIncome, '收入'); + $expectedExpense = $this->controlAmount($expectedExpense, '支出'); + + $batchSize = min(max($batchSize, 100), 5000); + $hash = hash_file('sha256', $path); + if (! is_string($hash)) { + throw new RuntimeException('无法计算得物完整账单文件摘要。'); + } + + $existing = $this->connection->table(self::IMPORT_TABLE) + ->where('source_sha256', $hash) + ->first(); + if ($existing !== null && $existing->status === 'succeeded') { + $this->assertSameImportRequest( + $existing, + $from, + $to, + $expectedCount, + $expectedIncome, + $expectedExpense, + ); + if ($this->activeRowCount($hash, $from, $to) === (int) $existing->row_count) { + return $this->result((array) $existing, 'unchanged'); + } + } + + $generation = $existing !== null ? (string) $existing->generation : (string) Str::uuid(); + $now = now(); + $import = [ + 'generation' => $generation, + 'source_filename' => mb_substr(basename($path), 0, 255), + 'source_sha256' => $hash, + 'period_start' => $from->format('Y-m-d'), + 'period_end' => $to->format('Y-m-d'), + 'status' => 'running', + 'expected_row_count' => $expectedCount, + 'expected_income_total' => $expectedIncome, + 'expected_expense_total' => $expectedExpense, + 'row_count' => 0, + 'excluded_rows' => 0, + 'store_count' => 0, + 'bill_item_count' => 0, + 'channel_counts' => null, + 'income_total' => '0.00', + 'expense_total' => '0.00', + 'net_total' => '0.00', + 'completed_at' => null, + 'last_error_code' => null, + 'updated_at' => $now, + ]; + if ($existing === null) { + $this->connection->table(self::IMPORT_TABLE)->insert($import + ['created_at' => $now]); + } else { + $this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update($import); + } + + $stats = null; + try { + $stats = $this->stage($path, $generation, $from, $to, $batchSize); + $this->assertControls($stats, $expectedCount, $expectedIncome, $expectedExpense); + $this->activate($generation, $hash, $from, $to, $stats, $batchSize); + + return $this->result([ + 'row_count' => $stats['row_count'], + 'excluded_rows' => $stats['excluded_rows'], + 'store_count' => $stats['store_count'], + 'bill_item_count' => $stats['bill_item_count'], + 'channel_counts' => $stats['channel_counts'], + 'income_total' => $stats['income_total'], + 'expense_total' => $stats['expense_total'], + 'net_total' => $stats['net_total'], + 'period_start' => $from->format('Y-m-d'), + 'period_end' => $to->format('Y-m-d'), + 'source_sha256' => $hash, + ], 'succeeded'); + } catch (Throwable $exception) { + $this->connection->table(self::STAGING_TABLE)->where('generation', $generation)->delete(); + $failure = [ + 'status' => 'failed', + 'last_error_code' => mb_substr(class_basename($exception), 0, 120), + 'updated_at' => now(), + ]; + if (is_array($stats)) { + $failure += [ + 'row_count' => $stats['row_count'], + 'excluded_rows' => $stats['excluded_rows'], + 'store_count' => $stats['store_count'], + 'bill_item_count' => $stats['bill_item_count'], + 'channel_counts' => json_encode($stats['channel_counts'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'income_total' => $stats['income_total'], + 'expense_total' => $stats['expense_total'], + 'net_total' => $stats['net_total'], + ]; + } + $this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update($failure); + + throw $exception; + } + } + + private function validatedPath(string $path): string + { + $realPath = realpath($path); + if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) { + throw new RuntimeException('得物完整账单文件不存在或不可读。'); + } + if (strtolower((string) pathinfo($realPath, PATHINFO_EXTENSION)) !== 'xlsx') { + throw new RuntimeException('聚胜算完整账单导入只支持 XLSX 文件。'); + } + + return $realPath; + } + + /** @return array{0:DateTimeImmutable,1:DateTimeImmutable} */ + private function period(string $fromDate, string $toDate): array + { + $from = DateTimeImmutable::createFromFormat('!Y-m-d', $fromDate); + $to = DateTimeImmutable::createFromFormat('!Y-m-d', $toDate); + if ($from === false || $to === false + || $from->format('Y-m-d') !== $fromDate + || $to->format('Y-m-d') !== $toDate + || $from > $to) { + throw new RuntimeException('得物账单导入账期无效。'); + } + + return [$from, $to]; + } + + private function assertSchemaReady(): void + { + $schema = Schema::connection($this->connection->getName()); + foreach ([self::IMPORT_TABLE, self::STAGING_TABLE, self::ROW_TABLE] as $table) { + if (! $schema->hasTable($table)) { + throw new RuntimeException('得物完整账单表尚未完成迁移:' . $table); + } + } + } + + /** + * @return array{row_count:int,excluded_rows:int,store_count:int,bill_item_count:int,channel_counts:array,income_total:string,expense_total:string,net_total:string} + */ + private function stage( + string $path, + string $generation, + DateTimeImmutable $from, + DateTimeImmutable $to, + int $batchSize, + ): array { + $archive = new ZipArchive(); + if ($archive->open($path) !== true) { + throw new RuntimeException('无法打开得物完整账单 XLSX 文件。'); + } + + try { + $worksheet = $this->firstWorksheet($archive); + $sharedStrings = $this->sharedStrings($path, $archive); + } finally { + $archive->close(); + } + + $reader = new XMLReader(); + if (! $reader->open($this->zipUri($path, $worksheet), null, LIBXML_NONET | LIBXML_COMPACT)) { + throw new RuntimeException('无法读取得物完整账单工作表。'); + } + + $headerMap = null; + $batch = []; + $rowCount = 0; + $incomeTotal = '0.00'; + $expenseTotal = '0.00'; + $stores = []; + $billItems = []; + $channelCounts = []; + + try { + while ($reader->read()) { + if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') { + continue; + } + + $rowNumber = (int) ($reader->getAttribute('r') ?: 0); + $cells = $this->rowCells($reader->readOuterXml(), $sharedStrings); + if ($headerMap === null) { + $headerMap = $this->headerMap($cells); + continue; + } + + $values = []; + foreach ($headerMap as $header => $column) { + $values[$header] = trim((string) ($cells[$column] ?? '')); + } + if (implode('', $values) === '') { + continue; + } + $recordCode = $this->required($values, 'record_code', $rowNumber, '记录编码'); + $channel = $this->required($values, 'channel', $rowNumber, '渠道'); + $storeName = $this->nullable($values['store_name']); + $billItem = $this->nullable($values['bill_item']); + $paidRefundedAt = $this->optionalDateTime($values['paid_refunded_at'], $rowNumber, '支付/退款时间'); + $shipReturnAt = $this->optionalDateTime($values['ship_return_at'], $rowNumber, '发货/退货时间'); + $billDate = $shipReturnAt ?? $paidRefundedAt; + if ($billDate === null) { + throw new RuntimeException("第 {$rowNumber} 行缺少可用于账期核验的发货/退货时间或支付/退款时间。"); + } + if ($billDate < $from->format('Y-m-d') . ' 00:00:00' + || $billDate >= $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00') { + throw new RuntimeException("第 {$rowNumber} 行账单时间超出指定账期。"); + } + + $income = $this->money($values['income_amount'], $rowNumber, '收入金额', false); + $expense = $this->money($values['expense_amount'], $rowNumber, '支出金额', true); + $signed = bcsub($income, $expense, 2); + + $batch[] = [ + 'generation' => $generation, + 'record_code' => mb_substr($recordCode, 0, 191), + 'order_no' => $this->nullable($values['order_no'], 191), + 'online_order_no' => $this->nullable($values['online_order_no'], 191), + 'custom_remark' => $this->nullable($values['custom_remark']), + 'bill_item' => $billItem, + 'store_name' => $storeName, + 'order_type' => $this->nullable($values['order_type']), + 'goods_name' => $this->nullable($values['goods_name'], 1000), + 'goods_no' => $this->nullable($values['goods_no'], 191), + 'quantity' => $this->nullable($values['quantity'], 64), + 'channel' => mb_substr($channel, 0, 255), + 'business_type' => $this->nullable($values['business_type']), + 'income_amount' => $income, + 'expense_amount' => $expense, + 'signed_amount' => $signed, + 'online_real_store' => $this->nullable($values['online_real_store']), + 'order_created_at' => $this->optionalDateTime($values['order_created_at'], $rowNumber, '订单创建时间'), + 'paid_refunded_at' => $paidRefundedAt, + 'ship_return_at' => $shipReturnAt, + 'bill_date' => $billDate, + 'settle_status' => $this->nullable($values['settle_status']), + 'pay_period' => $this->nullable($values['pay_period']), + 'pulled_at' => $this->optionalDateTime($values['pulled_at'], $rowNumber, '拉取时间'), + 'source_updated_at' => $this->optionalDateTime($values['source_updated_at'], $rowNumber, '修改时间'), + 'source_modified_by' => $this->nullable($values['source_modified_by']), + 'source_row_number' => $rowNumber, + ]; + $rowCount++; + if ($storeName !== null) { + $stores[$storeName] = true; + } + if ($billItem !== null) { + $billItems[$billItem] = true; + } + $channelCounts[$channel] = ($channelCounts[$channel] ?? 0) + 1; + $incomeTotal = bcadd($incomeTotal, $income, 2); + $expenseTotal = bcadd($expenseTotal, $expense, 2); + + if (count($batch) >= $batchSize) { + $this->connection->table(self::STAGING_TABLE)->insert($batch); + $batch = []; + } + } + } finally { + $reader->close(); + } + + if ($headerMap === null) { + throw new RuntimeException('未找到聚胜算得物账单标准表头。'); + } + if ($batch !== []) { + $this->connection->table(self::STAGING_TABLE)->insert($batch); + } + if ($rowCount === 0) { + throw new RuntimeException('文件中没有得物完整账单数据。'); + } + + return [ + 'row_count' => $rowCount, + 'excluded_rows' => 0, + 'store_count' => count($stores), + 'bill_item_count' => count($billItems), + 'channel_counts' => $channelCounts, + 'income_total' => $incomeTotal, + 'expense_total' => $expenseTotal, + 'net_total' => bcsub($incomeTotal, $expenseTotal, 2), + ]; + } + + private function assertControls(array $stats, int $count, string $income, string $expense): void + { + if ($stats['row_count'] !== $count + || bccomp($stats['income_total'], $income, 2) !== 0 + || bccomp($stats['expense_total'], $expense, 2) !== 0) { + throw new RuntimeException(sprintf( + '权威控制数不一致:实际 %d 笔,收入 %s,支出 %s;期望 %d 笔,收入 %s,支出 %s。', + $stats['row_count'], + $stats['income_total'], + $stats['expense_total'], + $count, + $income, + $expense, + )); + } + } + + private function assertSameImportRequest( + object $existing, + DateTimeImmutable $from, + DateTimeImmutable $to, + int $expectedCount, + string $expectedIncome, + string $expectedExpense, + ): void { + if ((string) $existing->period_start !== $from->format('Y-m-d') + || (string) $existing->period_end !== $to->format('Y-m-d') + || (int) $existing->expected_row_count !== $expectedCount + || bccomp((string) $existing->expected_income_total, $expectedIncome, 2) !== 0 + || bccomp((string) $existing->expected_expense_total, $expectedExpense, 2) !== 0) { + throw new RuntimeException('该文件已有成功导入记录,但本次账期或权威控制数与历史记录不一致。'); + } + } + + private function activeRowCount( + string $hash, + DateTimeImmutable $from, + DateTimeImmutable $to, + ): int { + return $this->connection->table(self::ROW_TABLE) + ->where('source_sha256', $hash) + ->where('bill_date', '>=', $from->format('Y-m-d') . ' 00:00:00') + ->where('bill_date', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00') + ->count(); + } + + private function activate( + string $generation, + string $hash, + DateTimeImmutable $from, + DateTimeImmutable $to, + array $stats, + int $batchSize, + ): void { + $now = now(); + $columns = [ + 'order_no', 'online_order_no', 'custom_remark', 'bill_item', 'store_name', 'order_type', + 'goods_name', 'goods_no', 'quantity', 'channel', 'business_type', 'income_amount', + 'expense_amount', 'signed_amount', 'online_real_store', 'order_created_at', + 'paid_refunded_at', 'ship_return_at', 'bill_date', 'settle_status', 'pay_period', + 'pulled_at', 'source_updated_at', 'source_modified_by', + 'source_row_number', 'source_generation', 'source_sha256', 'imported_at', + ]; + + $this->connection->transaction(function () use ( + $generation, $hash, $from, $to, $stats, $batchSize, $columns, $now + ): void { + $this->connection->table(self::ROW_TABLE) + ->where('bill_date', '>=', $from->format('Y-m-d') . ' 00:00:00') + ->where('bill_date', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00') + ->delete(); + + $this->connection->table(self::STAGING_TABLE) + ->where('generation', $generation) + ->orderBy('id') + ->chunkById($batchSize, function ($rows) use ($generation, $hash, $columns, $now): void { + $payload = []; + foreach ($rows as $row) { + $payload[] = [ + 'record_code' => $row->record_code, + 'order_no' => $row->order_no, + 'online_order_no' => $row->online_order_no, + 'custom_remark' => $row->custom_remark, + 'bill_item' => $row->bill_item, + 'store_name' => $row->store_name, + 'order_type' => $row->order_type, + 'goods_name' => $row->goods_name, + 'goods_no' => $row->goods_no, + 'quantity' => $row->quantity, + 'channel' => $row->channel, + 'business_type' => $row->business_type, + 'income_amount' => $row->income_amount, + 'expense_amount' => $row->expense_amount, + 'signed_amount' => $row->signed_amount, + 'online_real_store' => $row->online_real_store, + 'order_created_at' => $row->order_created_at, + 'paid_refunded_at' => $row->paid_refunded_at, + 'ship_return_at' => $row->ship_return_at, + 'bill_date' => $row->bill_date, + 'settle_status' => $row->settle_status, + 'pay_period' => $row->pay_period, + 'pulled_at' => $row->pulled_at, + 'source_updated_at' => $row->source_updated_at, + 'source_modified_by' => $row->source_modified_by, + 'source_row_number' => $row->source_row_number, + 'source_generation' => $generation, + 'source_sha256' => $hash, + 'imported_at' => $now, + ]; + } + $this->connection->table(self::ROW_TABLE)->upsert($payload, ['record_code'], $columns); + }); + + $this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update([ + 'status' => 'succeeded', + 'row_count' => $stats['row_count'], + 'excluded_rows' => $stats['excluded_rows'], + 'store_count' => $stats['store_count'], + 'bill_item_count' => $stats['bill_item_count'], + 'channel_counts' => json_encode($stats['channel_counts'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'income_total' => $stats['income_total'], + 'expense_total' => $stats['expense_total'], + 'net_total' => $stats['net_total'], + 'completed_at' => $now, + 'last_error_code' => null, + 'updated_at' => $now, + ]); + }); + + $this->connection->table(self::STAGING_TABLE)->where('generation', $generation)->delete(); + } + + /** @return array */ + private function rowCells(string $xml, array $sharedStrings): array + { + $row = simplexml_load_string($xml); + if (! $row instanceof SimpleXMLElement) { + return []; + } + + $cells = []; + foreach ($row->xpath('./*[local-name()="c"]') ?: [] as $cell) { + $reference = (string) ($cell['r'] ?? ''); + if (preg_match('/^([A-Z]+)\d+$/i', $reference, $matches) !== 1) { + continue; + } + $type = (string) ($cell['t'] ?? ''); + if ($type === 'inlineStr') { + $nodes = $cell->xpath('.//*[local-name()="t"]') ?: []; + $value = implode('', array_map(static fn (SimpleXMLElement $node): string => (string) $node, $nodes)); + } else { + $nodes = $cell->xpath('./*[local-name()="v"]') ?: []; + $raw = isset($nodes[0]) ? (string) $nodes[0] : ''; + $value = $type === 's' ? (string) ($sharedStrings[(int) $raw] ?? '') : $raw; + } + $cells[$this->columnIndex($matches[1])] = $value; + } + + return $cells; + } + + /** @param array $cells @return array */ + private function headerMap(array $cells): ?array + { + $lookup = []; + foreach ($cells as $column => $value) { + $lookup[trim($value)] = $column; + } + + $mapping = []; + foreach (self::HEADER_ALIASES as $field => $aliases) { + foreach ($aliases as $alias) { + if (array_key_exists($alias, $lookup)) { + $mapping[$field] = $lookup[$alias]; + break; + } + } + if (! array_key_exists($field, $mapping)) { + return null; + } + } + + return $mapping; + } + + private function required(array $values, string $key, int $row, ?string $label = null): string + { + $value = trim((string) ($values[$key] ?? '')); + if ($value === '') { + throw new RuntimeException("第 {$row} 行缺少" . ($label ?? $key) . '。'); + } + + return $value; + } + + private function money( + string $value, + int $row, + string $field, + bool $absolute = false, + bool $allowNegative = false, + ): string { + $value = str_replace([',', ',', '¥', '¥'], '', trim($value)); + $value = $value === '' ? '0' : $value; + if (! is_numeric($value) || (! $allowNegative && ! $absolute && bccomp($value, '0', 6) < 0)) { + throw new RuntimeException("第 {$row} 行{$field}不是有效金额。"); + } + if ($absolute && bccomp($value, '0', 6) < 0) { + $value = bcmul($value, '-1', 6); + } + + return bcadd($value, '0', 2); + } + + private function controlAmount(string $value, string $label): string + { + $value = str_replace([',', ',', '¥', '¥'], '', trim($value)); + if (! is_numeric($value) || bccomp($value, '0', 6) < 0) { + throw new RuntimeException("权威{$label}控制数无效。"); + } + + return bcadd($value, '0', 2); + } + + private function dateTime(string $value, int $row, string $field): string + { + $parsed = $this->parseDateTime($value); + if ($parsed === null) { + throw new RuntimeException("第 {$row} 行{$field}不是有效日期时间。"); + } + + return $parsed; + } + + private function optionalDateTime(string $value, int $row, string $field): ?string + { + if (trim($value) === '') { + return null; + } + + return $this->dateTime($value, $row, $field); + } + + private function parseDateTime(string $value): ?string + { + $value = trim($value); + try { + if (is_numeric($value)) { + $seconds = (int) round((((float) $value) - 25569) * 86400); + + return Carbon::createFromTimestampUTC($seconds)->format('Y-m-d H:i:s'); + } + + return Carbon::parse($value)->format('Y-m-d H:i:s'); + } catch (Throwable) { + return null; + } + } + + private function nullable(string $value, int $max = 255): ?string + { + $value = trim($value); + + return $value === '' ? null : mb_substr($value, 0, $max); + } + + private function columnIndex(string $letters): int + { + $index = 0; + foreach (str_split(strtoupper($letters)) as $letter) { + $index = ($index * 26) + ord($letter) - 64; + } + + return $index - 1; + } + + /** @return list */ + private function sharedStrings(string $path, ZipArchive $archive): array + { + if ($archive->locateName('xl/sharedStrings.xml') === false) { + return []; + } + + $reader = new XMLReader(); + if (! $reader->open($this->zipUri($path, 'xl/sharedStrings.xml'), null, LIBXML_NONET | LIBXML_COMPACT)) { + throw new RuntimeException('无法读取 XLSX 共享文本。'); + } + + $strings = []; + try { + while ($reader->read()) { + if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'si') { + continue; + } + $item = simplexml_load_string($reader->readOuterXml()); + $nodes = $item instanceof SimpleXMLElement ? ($item->xpath('.//*[local-name()="t"]') ?: []) : []; + $strings[] = implode('', array_map( + static fn (SimpleXMLElement $node): string => (string) $node, + $nodes, + )); + } + } finally { + $reader->close(); + } + + return $strings; + } + + private function firstWorksheet(ZipArchive $archive): string + { + $worksheets = []; + for ($index = 0; $index < $archive->numFiles; $index++) { + $name = $archive->getNameIndex($index); + if (is_string($name) && preg_match('#^xl/worksheets/sheet\d+\.xml$#', $name) === 1) { + $worksheets[] = $name; + } + } + sort($worksheets, SORT_NATURAL); + if ($worksheets === []) { + throw new RuntimeException('XLSX 文件中没有工作表。'); + } + + return $worksheets[0]; + } + + private function zipUri(string $path, string $entry): string + { + return 'zip://' . str_replace('\\', '/', $path) . '#' . $entry; + } + + private function result(array $row, string $status): array + { + return [ + 'status' => $status, + 'row_count' => (int) ($row['row_count'] ?? 0), + 'excluded_rows' => (int) ($row['excluded_rows'] ?? 0), + 'store_count' => (int) ($row['store_count'] ?? 0), + 'bill_item_count' => (int) ($row['bill_item_count'] ?? 0), + 'channel_counts' => $this->channelCounts($row['channel_counts'] ?? []), + 'income_total' => bcadd((string) ($row['income_total'] ?? '0'), '0', 2), + 'expense_total' => bcadd((string) ($row['expense_total'] ?? '0'), '0', 2), + 'net_total' => bcadd((string) ($row['net_total'] ?? '0'), '0', 2), + 'period_start' => (string) ($row['period_start'] ?? ''), + 'period_end' => (string) ($row['period_end'] ?? ''), + 'source_sha256' => (string) ($row['source_sha256'] ?? ''), + ]; + } + + /** @return array */ + private function channelCounts(mixed $value): array + { + if (is_string($value)) { + try { + $value = json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } catch (Throwable) { + return []; + } + } + if (! is_array($value)) { + return []; + } + + $counts = []; + foreach ($value as $channel => $count) { + if (is_string($channel) && $channel !== '' && is_numeric($count)) { + $counts[$channel] = (int) $count; + } + } + + return $counts; + } +} diff --git a/modules/EcommerceFinance/Services/DouyinAuthoritativeBillImporter.php b/modules/EcommerceFinance/Services/DouyinAuthoritativeBillImporter.php index 9fd0a47d..7d8e3635 100644 --- a/modules/EcommerceFinance/Services/DouyinAuthoritativeBillImporter.php +++ b/modules/EcommerceFinance/Services/DouyinAuthoritativeBillImporter.php @@ -9,18 +9,26 @@ use InvalidArgumentException; use RuntimeException; use Throwable; -/** Imports a complete, explicitly reconciled month without changing upstream bills. */ +/** Imports a reconciled month-to-date range without changing upstream bills. */ class DouyinAuthoritativeBillImporter { public function __construct(private readonly DatabaseManager $databases) {} - public function import(string $path, string $month, int $expectedCount, string $expectedIncome, string $expectedExpense): array + public function import(string $path, string $month, int $expectedCount, string $expectedIncome, string $expectedExpense, ?string $through = null): array { if (! preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) || $expectedCount < 1) { throw new InvalidArgumentException('必须指定完整月份和核对后的笔数。'); } $start = $month . '-01'; $end = Carbon::parse($start)->addMonth()->format('Y-m-d'); + if ($through !== null) { + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $through) + || ! checkdate((int) substr($through, 5, 2), (int) substr($through, 8, 2), (int) substr($through, 0, 4)) + || $through < $start || $through >= $end) { + throw new InvalidArgumentException('截止日期必须是指定月份内的有效日期。'); + } + $end = Carbon::parse($through)->addDay()->format('Y-m-d'); + } $expectedIncomeCents = $this->cents($expectedIncome); $expectedExpenseCents = $this->cents($expectedExpense); $file = fopen($path, 'rb'); @@ -91,7 +99,13 @@ class DouyinAuthoritativeBillImporter 'row_count' => $count, 'income' => $this->decimal($income), 'expense' => $this->decimal($expense), 'imported_at' => now()->format('Y-m-d H:i:s'), ]; - $db->transaction(fn () => $db->table('douyin_authoritative_periods')->updateOrInsert(['period_start' => $start], $state)); + $db->transaction(function () use ($db, $start, $end, $state): void { + $previous = $db->table('douyin_authoritative_periods')->where('period_start', $start)->lockForUpdate()->first(); + if ($previous !== null && $previous->period_end > $end) { + throw new InvalidArgumentException('截止日期早于已校准范围,请导出包含已校准日期的完整结果。'); + } + $db->table('douyin_authoritative_periods')->updateOrInsert(['period_start' => $start], $state); + }); return $state; } catch (Throwable $e) { $db->table('douyin_authoritative_bills')->where('generation', $generation)->delete(); diff --git a/modules/EcommerceFinance/Services/JdDailyBillImporter.php b/modules/EcommerceFinance/Services/JdDailyBillImporter.php index 3a871b5e..7dae4ba9 100644 --- a/modules/EcommerceFinance/Services/JdDailyBillImporter.php +++ b/modules/EcommerceFinance/Services/JdDailyBillImporter.php @@ -24,7 +24,7 @@ class JdDailyBillImporter public function __construct(private readonly DatabaseManager $databases) {} - public function import(string $path, string $month, int $expectedCount, string $expectedIncome, string $expectedExpense): array + public function import(string $path, string $month, int $expectedCount, string $expectedIncome, string $expectedExpense, ?string $through = null): array { if (! preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) || $expectedCount < 1) { throw new InvalidArgumentException('必须指定完整月份及核对笔数。'); @@ -33,6 +33,13 @@ class JdDailyBillImporter $expectedOut = $this->cents($expectedExpense); $start = $month . '-01'; $end = Carbon::parse($start)->addMonth()->format('Y-m-d'); + if ($through !== null) { + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $through); + if (! $date || $date->format('Y-m-d') !== $through || $through < $start || $through >= $end) { + throw new InvalidArgumentException('截止日期必须是指定月份内的有效日期。'); + } + $end = $date->modify('+1 day')->format('Y-m-d'); + } $file = fopen($path, 'rb'); if ($file === false) throw new RuntimeException('无法读取京东日账单明细。'); $db = $this->databases->connection(config('upstream_mirrors.connection', 'business_mirror')); @@ -95,8 +102,14 @@ class JdDailyBillImporter 'row_count' => $count, 'income' => $this->decimal($income), 'expense' => $this->decimal($expense), 'imported_at' => now()->format('Y-m-d H:i:s'), ]; - // Publish only reconciled complete months; keep the preceding generation for rollback. - $db->transaction(fn () => $db->table('jd_daily_bill_periods')->updateOrInsert(['period_start' => $start], $state)); + // Publish only the reconciled date range; keep the preceding generation for rollback. + $db->transaction(function () use ($db, $start, $end, $state): void { + $previous = $db->table('jd_daily_bill_periods')->where('period_start', $start)->lockForUpdate()->first(); + if ($previous !== null && $previous->period_end > $end) { + throw new InvalidArgumentException('截止日期早于已导入范围,未替换现有账单。'); + } + $db->table('jd_daily_bill_periods')->updateOrInsert(['period_start' => $start], $state); + }); return $state; } catch (Throwable $e) { $db->table('jd_daily_bill_rows')->where('generation', $generation)->delete(); diff --git a/modules/EcommerceFinance/Services/Jushuitan/QimenClient.php b/modules/EcommerceFinance/Services/Jushuitan/QimenClient.php new file mode 100644 index 00000000..40a26167 --- /dev/null +++ b/modules/EcommerceFinance/Services/Jushuitan/QimenClient.php @@ -0,0 +1,130 @@ +assertConfigured(); + + $bodyJson = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($bodyJson === false) { + throw new RuntimeException('聚水潭请求体序列化失败。'); + } + + $baseParams = $this->baseParams($method); + $baseParams['sign'] = $this->sign($baseParams, $bodyJson); + + $sync = $this->config['sync'] ?? []; + $timeout = (int) ($sync['request_timeout'] ?? 30); + $retries = max((int) ($sync['retry_times'] ?? 5), 1); + $retrySleep = (int) ($sync['retry_sleep_ms'] ?? 1000); + + $response = Http::withOptions(['query' => $baseParams]) + ->withHeaders(['Content-Type' => 'application/json']) + ->timeout($timeout) + ->retry($retries, $retrySleep) + ->withBody($bodyJson, 'application/json') + ->post($this->gatewayUrl()); + + if ($response->failed()) { + Log::warning('聚水潭奇门接口 HTTP 失败。', ['method' => $method, 'status' => $response->status()]); + throw new RuntimeException("聚水潭接口 {$method} 请求失败:HTTP {$response->status()}"); + } + + $data = $response->json(); + if (! is_array($data)) { + throw new RuntimeException("聚水潭接口 {$method} 返回非 JSON。"); + } + + // 奇门通常以 code=0 / issuccess 标识成功,具体字段以实际返回为准,此处仅做基础校验。 + return $data; + } + + /** @return array */ + private function baseParams(string $method): array + { + return [ + 'app_key' => (string) ($this->config['app_key'] ?? ''), + 'customerId' => (string) ($this->config['customer_id'] ?? ''), + 'format' => (string) ($this->config['format'] ?? 'json'), + 'method' => $method, + 'simplify' => (string) ($this->config['simplify'] ?? 'true'), + 'timestamp' => now()->format('Y-m-d H:i:s'), + 'v' => (string) ($this->config['version'] ?? '2'), + ]; + } + + /** + * 奇门 MD5 签名。等价于工作平台 ErpApiHelper.SignTopRequest: + * raw = secret + Σ(key+value,按固定顺序,跳过 sign) + body + secret + */ + private function sign(array $baseParams, string $bodyJson): string + { + $secret = (string) ($this->config['app_secret'] ?? ''); + $raw = $secret; + foreach (self::SIGN_ORDER as $key) { + $value = $baseParams[$key] ?? null; + if ($value !== null && $value !== '') { + $raw .= $key . $value; + } + } + $raw .= $bodyJson . $secret; + + return strtoupper(md5($raw)); + } + + private function gatewayUrl(): string + { + $url = trim((string) ($this->config['gateway_url'] ?? '')); + if ($url === '') { + throw new RuntimeException('未配置聚水潭奇门网关地址(JST_QIMEN_URL)。'); + } + + return $url; + } + + private function assertConfigured(): void + { + foreach (['app_key' => 'JST_APP_KEY', 'app_secret' => 'JST_APP_SECRET', 'customer_id' => 'JST_CUSTOMER_ID'] as $key => $env) { + if (trim((string) ($this->config[$key] ?? '')) === '') { + throw new RuntimeException("未配置聚水潭凭证 {$env},已拒绝请求(不会同步任何数据)。"); + } + } + } +} diff --git a/modules/EcommerceFinance/Services/Jushuitan/ShippingDataSynchronizer.php b/modules/EcommerceFinance/Services/Jushuitan/ShippingDataSynchronizer.php new file mode 100644 index 00000000..4b18ec49 --- /dev/null +++ b/modules/EcommerceFinance/Services/Jushuitan/ShippingDataSynchronizer.php @@ -0,0 +1,278 @@ + 得到每单 id / modified + * 2) erp.sradesku.query 按 tradeId 拉单据完整明细 -> 主单 + items + skus + receiver + * 增量以 modified 为水位,带重叠窗口;按 source_id upsert 去重。 + * + * 注意:奇门返回的 JSON 字段路径以聚水潭实际返回为准,本骨架的取值点已用 + * pluck() 集中,联调时按真实返回调整即可,不影响主流程结构。 + */ +class ShippingDataSynchronizer +{ + private const DATASET = 'jst.sctrade'; + + private readonly ConnectionInterface $db; + + public function __construct( + private readonly QimenClient $client, + private readonly array $config, + ) { + $this->db = DB::connection($config['connection'] ?? 'business_mirror'); + } + + public static function make(): self + { + return new self(QimenClient::fromConfig(), (array) config('jushuitan')); + } + + /** + * 执行一次增量同步。 + * + * @param ?Carbon $from 起始修改时间;默认取水位 - overlap + * @param ?Carbon $to 截止修改时间;默认 now + */ + public function syncIncremental(?Carbon $from = null, ?Carbon $to = null): array + { + $sync = $this->config['sync'] ?? []; + $overlap = max((int) ($sync['overlap_hours'] ?? 48), 0); + $limit = max((int) ($sync['page_limit'] ?? 100), 1); + $maxPages = (int) ($sync['max_pages'] ?? 0); + + $state = $this->state(); + $from ??= isset($state['watermark']) && $state['watermark'] + ? Carbon::parse($state['watermark'])->subHours($overlap) + : now()->subHours(max((int) ($sync['window_hours'] ?? 24), 1)); + $to ??= now(); + + $this->markRunning(); + $processed = 0; + $maxModified = $from; + + try { + $page = 1; + do { + $list = $this->client->call($this->method('sctrade_list'), [ + 'updateSt' => $from->format('Y-m-d H:i:s'), + 'updateEt' => $to->format('Y-m-d H:i:s'), + 'limit' => $limit, + 'page' => $page, + ]); + + $orders = $this->pluckOrders($list); + foreach ($orders as $order) { + $id = $this->pluckId($order); + if ($id === null) { + continue; + } + $detail = $this->client->call($this->method('sctrade_detail'), ['tradeId' => $id]); + $data = $detail['data'] ?? $detail; + $this->upsertOrder($data); + $processed++; + + $modified = $this->pluckModified($data); + if ($modified && (! $maxModified || $modified->gt($maxModified))) { + $maxModified = $modified; + } + } + + $hasNext = count($orders) >= $limit && ($maxPages === 0 || $page < $maxPages); + $page++; + } while ($hasNext); + + $this->markSucceeded($maxModified); + } catch (Throwable $e) { + $this->markFailed($e->getMessage()); + report($e); + throw $e; + } + + return ['processed' => $processed, 'watermark' => optional($maxModified)->toDateTimeString()]; + } + + /* ---------------- upsert(按 source_id 去重) ---------------- */ + + private function upsertOrder(array $data): void + { + $sourceId = $this->pluckId($data); + if ($sourceId === null) { + return; + } + $now = now(); + + $this->db->table('jst_sctrade')->updateOrInsert( + ['source_id' => $sourceId], + [ + 'sourceno' => $data['so_id'] ?? ($data['sourceno'] ?? null), + 'io_id' => $data['io_id'] ?? null, + 'shop_id' => $data['shop_id'] ?? null, + 'shopcode' => $data['shopcode'] ?? null, + 'wms_co_id' => $data['wms_co_id'] ?? null, + 'platform' => $data['platform'] ?? null, + 'status' => $data['status'] ?? null, + 'paytime' => $this->ts($data['pay_date'] ?? ($data['paytime'] ?? null)), + 'sended' => $this->ts($data['sended'] ?? null), + 'created' => $this->ts($data['created'] ?? null), + 'modified' => $this->ts($data['modified'] ?? null), + 'receiverstate' => $data['receiver_state'] ?? ($data['receiverstate'] ?? null), + 'receivercity' => $data['receiver_city'] ?? ($data['receivercity'] ?? null), + 'receiverdistrict' => $data['receiver_district'] ?? ($data['receiverdistrict'] ?? null), + 'receivername' => $data['receiver_name'] ?? ($data['receivername'] ?? null), + 'receivermobile' => $data['receiver_mobile'] ?? ($data['receivermobile'] ?? null), + 'receiverphone' => $data['receiver_phone'] ?? ($data['receiverphone'] ?? null), + 'receiveraddress' => $data['receiver_address'] ?? ($data['receiveraddress'] ?? null), + 'weight' => $data['weight'] ?? null, + 'freight' => $data['freight'] ?? null, + 'raw_json' => json_encode($data, JSON_UNESCAPED_UNICODE), + 'synced_at' => $now, + ] + ); + + foreach (($data['items'] ?? []) as $item) { + $iid = $item['id'] ?? null; + if ($iid === null) { + continue; + } + $this->db->table('jst_sctrade_items')->updateOrInsert( + ['source_id' => $iid], + [ + 'hid' => $sourceId, + 'oid' => $item['oid'] ?? null, + 'shopproid' => $item['shop_i_id'] ?? ($item['shopproid'] ?? null), + 'shoprocode' => $item['sku_id'] ?? ($item['shoprocode'] ?? null), + 'shopskucode' => $item['shopskucode'] ?? null, + 'shoproname' => $item['name'] ?? ($item['shoproname'] ?? null), + 'qty' => $item['qty'] ?? null, + 'raw_json' => json_encode($item, JSON_UNESCAPED_UNICODE), + 'synced_at' => $now, + ] + ); + } + + foreach (($data['tradeskus'] ?? ($data['skus'] ?? [])) as $sku) { + $sid = $sku['id'] ?? null; + if ($sid === null) { + continue; + } + $this->db->table('jst_sctrade_skus')->updateOrInsert( + ['source_id' => $sid], + [ + 'hid' => $sourceId, + 'procode' => $sku['sku_id'] ?? ($sku['procode'] ?? null), + 'sku_id' => $sku['sku_id'] ?? null, + 'proname' => $sku['name'] ?? ($sku['proname'] ?? null), + 'scmname' => $sku['scmname'] ?? null, + 'postcode' => $sku['postcode'] ?? ($sku['l_id'] ?? null), + 'logistics_company' => $sku['logistics_company'] ?? null, + 'qty' => $sku['qty'] ?? null, + 'sended' => $this->ts($sku['sended'] ?? null), + 'raw_json' => json_encode($sku, JSON_UNESCAPED_UNICODE), + 'synced_at' => $now, + ] + ); + } + + $receiver = $data['tradeReceiver'] ?? ($data['receiver'] ?? null); + if (is_array($receiver)) { + $this->db->table('jst_sctrade_receiver')->updateOrInsert( + ['hid' => $sourceId], + [ + 'receivername' => $receiver['receivername'] ?? ($receiver['name'] ?? null), + 'receiverstate' => $receiver['receiverstate'] ?? null, + 'receivercity' => $receiver['receivercity'] ?? null, + 'receiverdistrict' => $receiver['receiverdistrict'] ?? null, + 'receiveraddress' => $receiver['receiveraddress'] ?? null, + 'receivermobile' => $receiver['receivermobile'] ?? null, + 'receiverphone' => $receiver['receiverphone'] ?? null, + 'receiverzip' => $receiver['receiverzip'] ?? null, + 'raw_json' => json_encode($receiver, JSON_UNESCAPED_UNICODE), + 'synced_at' => $now, + ] + ); + } + } + + /* ---------------- 返回体取值点(按聚水潭实际返回调整) ---------------- */ + + /** @return array */ + private function pluckOrders(array $list): array + { + $data = $list['data'] ?? $list; + return $data['datas'] ?? ($data['orders'] ?? ($data['list'] ?? [])); + } + + private function pluckId(array $order): ?int + { + $id = $order['id'] ?? ($order['io_id'] ?? null); + return $id === null ? null : (int) $id; + } + + private function pluckModified(array $data): ?Carbon + { + return $this->ts($data['modified'] ?? null); + } + + private function ts(mixed $value): ?Carbon + { + if ($value === null || $value === '' ) { + return null; + } + try { + if (is_numeric($value)) { + $v = (int) $value; + return Carbon::createFromTimestamp($v > 9999999999 ? intdiv($v, 1000) : $v); + } + return Carbon::parse((string) $value); + } catch (Throwable) { + return null; + } + } + + /* ---------------- 同步状态 ---------------- */ + + private function state(): array + { + $row = $this->db->table('jst_sync_state')->where('dataset_key', self::DATASET)->first(); + return $row ? (array) $row : []; + } + + private function markRunning(): void + { + $this->db->table('jst_sync_state')->updateOrInsert( + ['dataset_key' => self::DATASET], + ['status' => 'running', 'last_started_at' => now(), 'updated_at' => now(), 'created_at' => now()] + ); + } + + private function markSucceeded(?Carbon $watermark): void + { + $update = ['status' => 'succeeded', 'last_succeeded_at' => now(), 'last_error' => null, 'updated_at' => now()]; + if ($watermark) { + $update['watermark'] = $watermark; + } + $this->db->table('jst_sync_state')->where('dataset_key', self::DATASET)->update($update); + } + + private function markFailed(string $error): void + { + $this->db->table('jst_sync_state')->where('dataset_key', self::DATASET)->update([ + 'status' => 'failed', 'last_error' => mb_substr($error, 0, 1000), 'updated_at' => now(), + ]); + } + + private function method(string $key): string + { + return $this->config['methods'][$key] ?? ''; + } +} diff --git a/modules/EcommerceFinance/Services/PinduoduoSettlementBillImporter.php b/modules/EcommerceFinance/Services/PinduoduoSettlementBillImporter.php new file mode 100644 index 00000000..635f46f6 --- /dev/null +++ b/modules/EcommerceFinance/Services/PinduoduoSettlementBillImporter.php @@ -0,0 +1,715 @@ + ['记录编码'], + 'store_name' => ['店铺', '原始店铺'], + 'custom_remark' => ['自定义备注'], + 'bill_item' => ['账单项目'], + 'merchant_order_no' => ['商户订单号'], + 'online_order_no' => ['线上单号'], + 'business_type' => ['财务类型', '业务类型'], + 'business_description' => ['业务描述'], + 'account_name' => ['类型', '账户'], + 'occurred_at' => ['发生时间'], + 'income_amount' => ['收入金额'], + 'expense_amount' => ['支出金额'], + 'signed_amount' => ['金额'], + 'remark' => ['备注'], + 'source_updated_at' => ['修改时间'], + 'source_modified_by' => ['修改人'], + ]; + + private const OPTIONAL_HEADERS = ['signed_amount']; + + private readonly ConnectionInterface $connection; + + public function __construct(DatabaseManager $databases) + { + $this->connection = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + /** + * @return array{status:string,row_count:int,excluded_account_rows:int,store_count:int,bill_item_count:int,account_type_counts:array,income_total:string,expense_total:string,net_total:string,period_start:string,period_end:string,source_sha256:string} + */ + public function import( + string $path, + string $fromDate, + string $toDate, + int $expectedCount, + string $expectedIncome, + string $expectedExpense, + int $batchSize = 1000, + ): array { + $path = $this->validatedPath($path); + [$from, $to] = $this->period($fromDate, $toDate); + $this->assertSchemaReady(); + if ($expectedCount <= 0) { + throw new RuntimeException('权威笔数控制数必须大于零。'); + } + $expectedIncome = $this->controlAmount($expectedIncome, '收入'); + $expectedExpense = $this->controlAmount($expectedExpense, '支出'); + + $batchSize = min(max($batchSize, 100), 5000); + $hash = hash_file('sha256', $path); + if (! is_string($hash)) { + throw new RuntimeException('无法计算拼多多完整账单文件摘要。'); + } + + $existing = $this->connection->table(self::IMPORT_TABLE) + ->where('source_sha256', $hash) + ->first(); + if ($existing !== null && $existing->status === 'succeeded') { + $this->assertSameImportRequest( + $existing, + $from, + $to, + $expectedCount, + $expectedIncome, + $expectedExpense, + ); + if ($this->activeRowCount($hash, $from, $to) === (int) $existing->row_count) { + return $this->result((array) $existing, 'unchanged'); + } + } + + $generation = $existing !== null ? (string) $existing->generation : (string) Str::uuid(); + $now = now(); + $import = [ + 'generation' => $generation, + 'source_filename' => mb_substr(basename($path), 0, 255), + 'source_sha256' => $hash, + 'period_start' => $from->format('Y-m-d'), + 'period_end' => $to->format('Y-m-d'), + 'status' => 'running', + 'expected_row_count' => $expectedCount, + 'expected_income_total' => $expectedIncome, + 'expected_expense_total' => $expectedExpense, + 'row_count' => 0, + 'excluded_account_rows' => 0, + 'store_count' => 0, + 'bill_item_count' => 0, + 'account_type_counts' => null, + 'income_total' => '0.00', + 'expense_total' => '0.00', + 'net_total' => '0.00', + 'completed_at' => null, + 'last_error_code' => null, + 'updated_at' => $now, + ]; + if ($existing === null) { + $this->connection->table(self::IMPORT_TABLE)->insert($import + ['created_at' => $now]); + } else { + $this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update($import); + } + + $stats = null; + try { + $stats = $this->stage($path, $generation, $from, $to, $batchSize); + $this->assertControls($stats, $expectedCount, $expectedIncome, $expectedExpense); + $this->activate($generation, $hash, $from, $to, $stats, $batchSize); + + return $this->result([ + 'row_count' => $stats['row_count'], + 'excluded_account_rows' => $stats['excluded_account_rows'], + 'store_count' => $stats['store_count'], + 'bill_item_count' => $stats['bill_item_count'], + 'account_type_counts' => $stats['account_type_counts'], + 'income_total' => $stats['income_total'], + 'expense_total' => $stats['expense_total'], + 'net_total' => $stats['net_total'], + 'period_start' => $from->format('Y-m-d'), + 'period_end' => $to->format('Y-m-d'), + 'source_sha256' => $hash, + ], 'succeeded'); + } catch (Throwable $exception) { + $this->connection->table(self::STAGING_TABLE)->where('generation', $generation)->delete(); + $failure = [ + 'status' => 'failed', + 'last_error_code' => mb_substr(class_basename($exception), 0, 120), + 'updated_at' => now(), + ]; + if (is_array($stats)) { + $failure += [ + 'row_count' => $stats['row_count'], + 'excluded_account_rows' => $stats['excluded_account_rows'], + 'store_count' => $stats['store_count'], + 'bill_item_count' => $stats['bill_item_count'], + 'account_type_counts' => json_encode($stats['account_type_counts'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'income_total' => $stats['income_total'], + 'expense_total' => $stats['expense_total'], + 'net_total' => $stats['net_total'], + ]; + } + $this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update($failure); + + throw $exception; + } + } + + private function validatedPath(string $path): string + { + $realPath = realpath($path); + if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) { + throw new RuntimeException('拼多多完整账单文件不存在或不可读。'); + } + if (strtolower((string) pathinfo($realPath, PATHINFO_EXTENSION)) !== 'xlsx') { + throw new RuntimeException('聚胜算完整账单导入只支持 XLSX 文件。'); + } + + return $realPath; + } + + /** @return array{0:DateTimeImmutable,1:DateTimeImmutable} */ + private function period(string $fromDate, string $toDate): array + { + $from = DateTimeImmutable::createFromFormat('!Y-m-d', $fromDate); + $to = DateTimeImmutable::createFromFormat('!Y-m-d', $toDate); + if ($from === false || $to === false + || $from->format('Y-m-d') !== $fromDate + || $to->format('Y-m-d') !== $toDate + || $from > $to) { + throw new RuntimeException('拼多多账单导入账期无效。'); + } + + return [$from, $to]; + } + + private function assertSchemaReady(): void + { + $schema = Schema::connection($this->connection->getName()); + foreach ([self::IMPORT_TABLE, self::STAGING_TABLE, self::ROW_TABLE] as $table) { + if (! $schema->hasTable($table)) { + throw new RuntimeException('拼多多完整账单表尚未完成迁移:' . $table); + } + } + } + + /** + * @return array{row_count:int,excluded_account_rows:int,store_count:int,bill_item_count:int,account_type_counts:array,income_total:string,expense_total:string,net_total:string} + */ + private function stage( + string $path, + string $generation, + DateTimeImmutable $from, + DateTimeImmutable $to, + int $batchSize, + ): array { + $archive = new ZipArchive(); + if ($archive->open($path) !== true) { + throw new RuntimeException('无法打开拼多多完整账单 XLSX 文件。'); + } + + try { + $worksheet = $this->firstWorksheet($archive); + $sharedStrings = $this->sharedStrings($path, $archive); + } finally { + $archive->close(); + } + + $reader = new XMLReader(); + if (! $reader->open($this->zipUri($path, $worksheet), null, LIBXML_NONET | LIBXML_COMPACT)) { + throw new RuntimeException('无法读取拼多多完整账单工作表。'); + } + + $headerMap = null; + $batch = []; + $rowCount = 0; + $excluded = 0; + $incomeTotal = '0.00'; + $expenseTotal = '0.00'; + $stores = []; + $billItems = []; + $accountTypeCounts = []; + + try { + while ($reader->read()) { + if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') { + continue; + } + + $rowNumber = (int) ($reader->getAttribute('r') ?: 0); + $cells = $this->rowCells($reader->readOuterXml(), $sharedStrings); + if ($headerMap === null) { + $headerMap = $this->headerMap($cells); + continue; + } + + $values = []; + foreach ($headerMap as $header => $column) { + $values[$header] = trim((string) ($cells[$column] ?? '')); + } + if (implode('', $values) === '') { + continue; + } + $recordCode = $this->required($values, 'record_code', $rowNumber, '记录编码'); + $accountName = $this->required($values, 'account_name', $rowNumber, '账户类型'); + $storeName = $this->nullable($values['store_name']); + $billItem = $this->nullable($values['bill_item']); + $occurredAt = $this->dateTime($values['occurred_at'], $rowNumber, '发生时间'); + if ($occurredAt < $from->format('Y-m-d') . ' 00:00:00' + || $occurredAt >= $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00') { + throw new RuntimeException("第 {$rowNumber} 行发生时间超出指定账期。"); + } + + $income = $this->money($values['income_amount'], $rowNumber, '收入金额', false); + $expense = $this->money($values['expense_amount'], $rowNumber, '支出金额', true); + $calculated = bcsub($income, $expense, 2); + $signed = $calculated; + if (array_key_exists('signed_amount', $headerMap)) { + $signed = $this->money($values['signed_amount'], $rowNumber, '金额', false, true); + if (bccomp($calculated, $signed, 2) !== 0) { + throw new RuntimeException("第 {$rowNumber} 行收入、支出与金额不一致。"); + } + } + + $batch[] = [ + 'generation' => $generation, + 'record_code' => mb_substr($recordCode, 0, 191), + 'store_name' => $storeName, + 'custom_remark' => $this->nullable($values['custom_remark']), + 'bill_item' => $billItem, + 'merchant_order_no' => $this->nullable($values['merchant_order_no'], 191), + 'online_order_no' => $this->nullable($values['online_order_no'], 191), + 'business_type' => $this->nullable($values['business_type']), + 'business_description' => $this->nullable($values['business_description'], 1000), + // Keep the account type from the authoritative export. The + // complete Jushengsuan file contains both wallet and marketing + // account rows, and the finance page must retain both. + 'account_name' => mb_substr($accountName, 0, 255), + 'occurred_at' => $occurredAt, + 'income_amount' => $income, + 'expense_amount' => $expense, + 'signed_amount' => $signed, + 'remark' => $this->nullable($values['remark'], 1000), + 'source_updated_at' => $this->optionalDateTime($values['source_updated_at'], $rowNumber, '修改时间'), + 'source_modified_by' => $this->nullable($values['source_modified_by']), + 'source_row_number' => $rowNumber, + ]; + $rowCount++; + if ($storeName !== null) { + $stores[$storeName] = true; + } + if ($billItem !== null) { + $billItems[$billItem] = true; + } + $accountTypeCounts[$accountName] = ($accountTypeCounts[$accountName] ?? 0) + 1; + $incomeTotal = bcadd($incomeTotal, $income, 2); + $expenseTotal = bcadd($expenseTotal, $expense, 2); + + if (count($batch) >= $batchSize) { + $this->connection->table(self::STAGING_TABLE)->insert($batch); + $batch = []; + } + } + } finally { + $reader->close(); + } + + if ($headerMap === null) { + throw new RuntimeException('未找到聚胜算拼多多账单标准表头。'); + } + if ($batch !== []) { + $this->connection->table(self::STAGING_TABLE)->insert($batch); + } + if ($rowCount === 0) { + throw new RuntimeException('文件中没有拼多多货款账户数据。'); + } + + return [ + 'row_count' => $rowCount, + 'excluded_account_rows' => $excluded, + 'store_count' => count($stores), + 'bill_item_count' => count($billItems), + 'account_type_counts' => $accountTypeCounts, + 'income_total' => $incomeTotal, + 'expense_total' => $expenseTotal, + 'net_total' => bcsub($incomeTotal, $expenseTotal, 2), + ]; + } + + private function assertControls(array $stats, int $count, string $income, string $expense): void + { + if ($stats['row_count'] !== $count + || bccomp($stats['income_total'], $income, 2) !== 0 + || bccomp($stats['expense_total'], $expense, 2) !== 0) { + throw new RuntimeException(sprintf( + '权威控制数不一致:实际 %d 笔,收入 %s,支出 %s;期望 %d 笔,收入 %s,支出 %s。', + $stats['row_count'], + $stats['income_total'], + $stats['expense_total'], + $count, + $income, + $expense, + )); + } + } + + private function assertSameImportRequest( + object $existing, + DateTimeImmutable $from, + DateTimeImmutable $to, + int $expectedCount, + string $expectedIncome, + string $expectedExpense, + ): void { + if ((string) $existing->period_start !== $from->format('Y-m-d') + || (string) $existing->period_end !== $to->format('Y-m-d') + || (int) $existing->expected_row_count !== $expectedCount + || bccomp((string) $existing->expected_income_total, $expectedIncome, 2) !== 0 + || bccomp((string) $existing->expected_expense_total, $expectedExpense, 2) !== 0) { + throw new RuntimeException('该文件已有成功导入记录,但本次账期或权威控制数与历史记录不一致。'); + } + } + + private function activeRowCount( + string $hash, + DateTimeImmutable $from, + DateTimeImmutable $to, + ): int { + return $this->connection->table(self::ROW_TABLE) + ->where('source_sha256', $hash) + ->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00') + ->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00') + ->count(); + } + + private function activate( + string $generation, + string $hash, + DateTimeImmutable $from, + DateTimeImmutable $to, + array $stats, + int $batchSize, + ): void { + $now = now(); + $columns = [ + 'store_name', 'custom_remark', 'bill_item', 'merchant_order_no', 'online_order_no', + 'business_type', 'business_description', 'account_name', 'occurred_at', 'income_amount', + 'expense_amount', 'signed_amount', 'remark', 'source_updated_at', 'source_modified_by', + 'source_row_number', 'source_generation', 'source_sha256', 'imported_at', + ]; + + $this->connection->transaction(function () use ( + $generation, $hash, $from, $to, $stats, $batchSize, $columns, $now + ): void { + $this->connection->table(self::ROW_TABLE) + ->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00') + ->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00') + ->delete(); + + $this->connection->table(self::STAGING_TABLE) + ->where('generation', $generation) + ->orderBy('id') + ->chunkById($batchSize, function ($rows) use ($generation, $hash, $columns, $now): void { + $payload = []; + foreach ($rows as $row) { + $payload[] = [ + 'record_code' => $row->record_code, + 'store_name' => $row->store_name, + 'custom_remark' => $row->custom_remark, + 'bill_item' => $row->bill_item, + 'merchant_order_no' => $row->merchant_order_no, + 'online_order_no' => $row->online_order_no, + 'business_type' => $row->business_type, + 'business_description' => $row->business_description, + 'account_name' => $row->account_name, + 'occurred_at' => $row->occurred_at, + 'income_amount' => $row->income_amount, + 'expense_amount' => $row->expense_amount, + 'signed_amount' => $row->signed_amount, + 'remark' => $row->remark, + 'source_updated_at' => $row->source_updated_at, + 'source_modified_by' => $row->source_modified_by, + 'source_row_number' => $row->source_row_number, + 'source_generation' => $generation, + 'source_sha256' => $hash, + 'imported_at' => $now, + ]; + } + $this->connection->table(self::ROW_TABLE)->upsert($payload, ['record_code'], $columns); + }); + + $this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update([ + 'status' => 'succeeded', + 'row_count' => $stats['row_count'], + 'excluded_account_rows' => $stats['excluded_account_rows'], + 'store_count' => $stats['store_count'], + 'bill_item_count' => $stats['bill_item_count'], + 'account_type_counts' => json_encode($stats['account_type_counts'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'income_total' => $stats['income_total'], + 'expense_total' => $stats['expense_total'], + 'net_total' => $stats['net_total'], + 'completed_at' => $now, + 'last_error_code' => null, + 'updated_at' => $now, + ]); + }); + + $this->connection->table(self::STAGING_TABLE)->where('generation', $generation)->delete(); + } + + /** @return array */ + private function rowCells(string $xml, array $sharedStrings): array + { + $row = simplexml_load_string($xml); + if (! $row instanceof SimpleXMLElement) { + return []; + } + + $cells = []; + foreach ($row->xpath('./*[local-name()="c"]') ?: [] as $cell) { + $reference = (string) ($cell['r'] ?? ''); + if (preg_match('/^([A-Z]+)\d+$/i', $reference, $matches) !== 1) { + continue; + } + $type = (string) ($cell['t'] ?? ''); + if ($type === 'inlineStr') { + $nodes = $cell->xpath('.//*[local-name()="t"]') ?: []; + $value = implode('', array_map(static fn (SimpleXMLElement $node): string => (string) $node, $nodes)); + } else { + $nodes = $cell->xpath('./*[local-name()="v"]') ?: []; + $raw = isset($nodes[0]) ? (string) $nodes[0] : ''; + $value = $type === 's' ? (string) ($sharedStrings[(int) $raw] ?? '') : $raw; + } + $cells[$this->columnIndex($matches[1])] = $value; + } + + return $cells; + } + + /** @param array $cells @return array */ + private function headerMap(array $cells): ?array + { + $lookup = []; + foreach ($cells as $column => $value) { + $lookup[trim($value)] = $column; + } + + $mapping = []; + foreach (self::HEADER_ALIASES as $field => $aliases) { + foreach ($aliases as $alias) { + if (array_key_exists($alias, $lookup)) { + $mapping[$field] = $lookup[$alias]; + break; + } + } + if (! array_key_exists($field, $mapping) + && ! in_array($field, self::OPTIONAL_HEADERS, true)) { + return null; + } + } + + return $mapping; + } + + private function required(array $values, string $key, int $row, ?string $label = null): string + { + $value = trim((string) ($values[$key] ?? '')); + if ($value === '') { + throw new RuntimeException("第 {$row} 行缺少" . ($label ?? $key) . '。'); + } + + return $value; + } + + private function money( + string $value, + int $row, + string $field, + bool $absolute = false, + bool $allowNegative = false, + ): string { + $value = str_replace([',', ',', '¥', '¥'], '', trim($value)); + $value = $value === '' ? '0' : $value; + if (! is_numeric($value) || (! $allowNegative && ! $absolute && bccomp($value, '0', 6) < 0)) { + throw new RuntimeException("第 {$row} 行{$field}不是有效金额。"); + } + if ($absolute && bccomp($value, '0', 6) < 0) { + $value = bcmul($value, '-1', 6); + } + + return bcadd($value, '0', 2); + } + + private function controlAmount(string $value, string $label): string + { + $value = str_replace([',', ',', '¥', '¥'], '', trim($value)); + if (! is_numeric($value) || bccomp($value, '0', 6) < 0) { + throw new RuntimeException("权威{$label}控制数无效。"); + } + + return bcadd($value, '0', 2); + } + + private function dateTime(string $value, int $row, string $field): string + { + $parsed = $this->parseDateTime($value); + if ($parsed === null) { + throw new RuntimeException("第 {$row} 行{$field}不是有效日期时间。"); + } + + return $parsed; + } + + private function optionalDateTime(string $value, int $row, string $field): ?string + { + if (trim($value) === '') { + return null; + } + + return $this->dateTime($value, $row, $field); + } + + private function parseDateTime(string $value): ?string + { + $value = trim($value); + try { + if (is_numeric($value)) { + $seconds = (int) round((((float) $value) - 25569) * 86400); + + return Carbon::createFromTimestampUTC($seconds)->format('Y-m-d H:i:s'); + } + + return Carbon::parse($value)->format('Y-m-d H:i:s'); + } catch (Throwable) { + return null; + } + } + + private function nullable(string $value, int $max = 255): ?string + { + $value = trim($value); + + return $value === '' ? null : mb_substr($value, 0, $max); + } + + private function columnIndex(string $letters): int + { + $index = 0; + foreach (str_split(strtoupper($letters)) as $letter) { + $index = ($index * 26) + ord($letter) - 64; + } + + return $index - 1; + } + + /** @return list */ + private function sharedStrings(string $path, ZipArchive $archive): array + { + if ($archive->locateName('xl/sharedStrings.xml') === false) { + return []; + } + + $reader = new XMLReader(); + if (! $reader->open($this->zipUri($path, 'xl/sharedStrings.xml'), null, LIBXML_NONET | LIBXML_COMPACT)) { + throw new RuntimeException('无法读取 XLSX 共享文本。'); + } + + $strings = []; + try { + while ($reader->read()) { + if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'si') { + continue; + } + $item = simplexml_load_string($reader->readOuterXml()); + $nodes = $item instanceof SimpleXMLElement ? ($item->xpath('.//*[local-name()="t"]') ?: []) : []; + $strings[] = implode('', array_map( + static fn (SimpleXMLElement $node): string => (string) $node, + $nodes, + )); + } + } finally { + $reader->close(); + } + + return $strings; + } + + private function firstWorksheet(ZipArchive $archive): string + { + $worksheets = []; + for ($index = 0; $index < $archive->numFiles; $index++) { + $name = $archive->getNameIndex($index); + if (is_string($name) && preg_match('#^xl/worksheets/sheet\d+\.xml$#', $name) === 1) { + $worksheets[] = $name; + } + } + sort($worksheets, SORT_NATURAL); + if ($worksheets === []) { + throw new RuntimeException('XLSX 文件中没有工作表。'); + } + + return $worksheets[0]; + } + + private function zipUri(string $path, string $entry): string + { + return 'zip://' . str_replace('\\', '/', $path) . '#' . $entry; + } + + private function result(array $row, string $status): array + { + return [ + 'status' => $status, + 'row_count' => (int) ($row['row_count'] ?? 0), + 'excluded_account_rows' => (int) ($row['excluded_account_rows'] ?? 0), + 'store_count' => (int) ($row['store_count'] ?? 0), + 'bill_item_count' => (int) ($row['bill_item_count'] ?? 0), + 'account_type_counts' => $this->accountTypeCounts($row['account_type_counts'] ?? []), + 'income_total' => bcadd((string) ($row['income_total'] ?? '0'), '0', 2), + 'expense_total' => bcadd((string) ($row['expense_total'] ?? '0'), '0', 2), + 'net_total' => bcadd((string) ($row['net_total'] ?? '0'), '0', 2), + 'period_start' => (string) ($row['period_start'] ?? ''), + 'period_end' => (string) ($row['period_end'] ?? ''), + 'source_sha256' => (string) ($row['source_sha256'] ?? ''), + ]; + } + + /** @return array */ + private function accountTypeCounts(mixed $value): array + { + if (is_string($value)) { + try { + $value = json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } catch (Throwable) { + return []; + } + } + if (! is_array($value)) { + return []; + } + + $counts = []; + foreach ($value as $account => $count) { + if (is_string($account) && $account !== '' && is_numeric($count)) { + $counts[$account] = (int) $count; + } + } + + return $counts; + } +} diff --git a/modules/EcommerceFinance/Services/ProductPriceWorkbookImporter.php b/modules/EcommerceFinance/Services/ProductPriceWorkbookImporter.php index 48394b17..8d77feae 100644 --- a/modules/EcommerceFinance/Services/ProductPriceWorkbookImporter.php +++ b/modules/EcommerceFinance/Services/ProductPriceWorkbookImporter.php @@ -39,6 +39,25 @@ class ProductPriceWorkbookImporter { } + /** @return array */ + public function templateFields(): array + { + $fields = []; + + foreach (self::HEADERS as $label => $field) { + if ($field === 'upstream_key') { + continue; + } + + $fields[$field] = [ + 'label' => $label, + 'type' => isset(self::TEXT_LIMITS[$field]) ? 'text' : 'number', + ]; + } + + return $fields; + } + /** @return array{created:int,updated:int,rows:int} */ public function import(UploadedFile $file, int $companyId, ?int $userId): array { diff --git a/modules/EcommerceFinance/Services/ProductPurchasePriceMirrorSynchronizer.php b/modules/EcommerceFinance/Services/ProductPurchasePriceMirrorSynchronizer.php index 0690d2aa..1a827005 100644 --- a/modules/EcommerceFinance/Services/ProductPurchasePriceMirrorSynchronizer.php +++ b/modules/EcommerceFinance/Services/ProductPurchasePriceMirrorSynchronizer.php @@ -25,7 +25,10 @@ class ProductPurchasePriceMirrorSynchronizer } $state = $this->mirror->state(); - if (! $full && ($state['initial_completed_at'] ?? null) === null) { + if (! $full && ( + ($state['initial_completed_at'] ?? null) === null + || (int) ($state['projection_version'] ?? 1) < ProductPurchasePriceMirrorRepository::CURRENT_PROJECTION_VERSION + )) { return ['status' => 'requires_full_sync', 'mode' => 'incremental', 'batches' => 0, 'rows' => 0]; } @@ -33,6 +36,7 @@ class ProductPurchasePriceMirrorSynchronizer $resume = is_array($state) && ($state['run_mode'] ?? null) === $mode && in_array($state['status'] ?? null, ['running', 'failed'], true) + && (int) ($state['projection_version'] ?? 1) >= ProductPurchasePriceMirrorRepository::CURRENT_PROJECTION_VERSION && ($state['window_started_at'] ?? null) !== null && ($state['window_ends_at'] ?? null) !== null; $generation = $resume && is_string($state['generation'] ?? null) diff --git a/modules/EcommerceFinance/Services/SupplierPayableImportTemplate.php b/modules/EcommerceFinance/Services/SupplierPayableImportTemplate.php new file mode 100644 index 00000000..837813c5 --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierPayableImportTemplate.php @@ -0,0 +1,89 @@ + $fields */ + public function download(string $title, array $fields): StreamedResponse + { + return response()->streamDownload(function () use ($title, $fields): void { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle($this->sheetTitle($title)); + + $headers = array_merge(['记录标识'], array_column($fields, 'label')); + $sheet->fromArray($headers, null, 'A1'); + $lastColumn = Coordinate::stringFromColumnIndex(count($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); + $sheet->getColumnDimension('A')->setWidth(38); + + $columnIndex = 2; + foreach ($fields as $definition) { + $column = Coordinate::stringFromColumnIndex($columnIndex); + $width = max(14, min(30, mb_strlen($definition['label']) * 2 + 4)); + $sheet->getColumnDimension($column)->setWidth($width); + if ($definition['type'] === 'number') { + $sheet->getStyle($column . '2:' . $column . '10001')->getNumberFormat()->setFormatCode('0.0000'); + } else { + $sheet->getStyle($column . '2:' . $column . '10001')->getNumberFormat()->setFormatCode('@'); + } + $columnIndex++; + } + $sheet->getStyle('A2:A10001')->getNumberFormat()->setFormatCode('@'); + + $instructions = $spreadsheet->createSheet(); + $instructions->setTitle('填写说明'); + $instructions->fromArray([ + ['项目', '说明'], + ['记录标识', '必填。请从本页面导出的文件中复制,不能修改。'], + ['日期', '请填写 YYYY-MM-DD,例如 2026-09-17。'], + ['月份', '请填写 YYYY-MM,例如 2026-09。'], + ['数字', '最多支持 4 位小数;金额可直接填写数字。'], + ['空白单元格', '导入后将清空该字段,请确认后再导入。'], + ], 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(58); + $instructions->getStyle('A1:B6')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER)->setWrapText(true); + foreach (range(1, 6) as $row) { + $instructions->getRowDimension($row)->setRowHeight($row === 1 ? 24 : 32); + } + + $spreadsheet->setActiveSheetIndex(0); + (new Xlsx($spreadsheet))->save('php://output'); + $spreadsheet->disconnectWorksheets(); + }, $title . '_导入模板_' . now()->format('Ymd') . '.xlsx', [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Cache-Control' => 'private, no-store', + ]); + } + + private function sheetTitle(string $title): string + { + $title = str_replace(['\\', '/', '?', '*', '[', ']', ':'], '', $title) ?: '导入模板'; + + return mb_substr($title, 0, 31); + } +} diff --git a/modules/EcommerceFinance/Services/SupplierPayableMaintenanceRegistry.php b/modules/EcommerceFinance/Services/SupplierPayableMaintenanceRegistry.php new file mode 100644 index 00000000..df1d9b3f --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierPayableMaintenanceRegistry.php @@ -0,0 +1,167 @@ + */ + public function fields(string $dataset): array + { + $money = static fn (string $label): array => ['label' => $label, 'type' => 'number']; + $text = static fn (string $label): array => ['label' => $label, 'type' => 'text']; + $date = static fn (string $label): array => ['label' => $label, 'type' => 'date']; + $month = static fn (string $label): array => ['label' => $label, 'type' => 'month']; + + return match ($dataset) { + 'payment-reconciliation' => [ + 'supplier' => $text('供应商'), 'skuCode' => $text('货号'), 'productCode' => $text('商品编码'), + 'skuName' => $text('规格名称'), 'qty' => $money('数量'), 'unitPrice' => $money('单价'), + 'amount' => $money('金额'), 'guessUnitPrice' => $money('预估单价'), 'guessAmount' => $money('预估金额'), + 'supplierGoodsCode' => $text('供应商商品编码'), 'remark' => $text('备注'), + 'warehouse' => $text('仓库名称'), 'expressName' => $text('快递名称'), 'expressNo' => $text('快递单号'), + ], + 'return-reconciliation', 'return-unpriced' => [ + 'send_time' => $date('发货时间'), 'supplier' => $text('明细供应商'), 'barcode' => $text('条形码'), + 'sku_name' => $text('商品名称'), 'qty' => $money('发货量'), 'unit_price' => $money('单价'), + 'amount' => $money('金额'), 'guess_unit_price' => $money('预估单价'), 'guess_amount' => $money('预估金额'), + 'supplier_barcode' => $text('供应商条码'), 'remark' => $text('备注'), 'title' => $text('标题'), + 'warehouse' => $text('仓库名称'), 'express_no' => $text('快递单号'), + 'express_name' => $text('快递名称'), 'labels' => $text('标记多标签'), 'reconciler' => $text('对账人'), + ], + 'invoice-deductions' => [ + 'month_str' => $month('日期'), 'category' => $text('类别'), 'payee' => $text('打款抬头'), + 'receiver' => $text('收款方'), 'board_factory' => $text('供板材厂家/代扣厂家'), + 'subsidy_fee' => $money('补贴费用'), 'remark' => $text('备注'), 'price' => $money('对私打款金额'), + 'detail' => $text('明细'), 'note' => $text('注明'), 'person_charge' => $text('负责人'), + ], + 'compensation-details' => [ + 'month' => $month('日期'), 'supplier' => $text('供应商'), 'amount' => $money('赔付金额'), + 'reason' => $text('赔付原因'), 'remark' => $text('备注'), + ], + 'advance-details' => [ + 'supplier' => $text('供应商'), 'contract_name' => $text('协议签订名称'), + 'advance_amount' => $money('借支金额'), 'actual_pay' => $money('实际打款金额'), + 'month_deduct' => $money('本月扣款金额'), 'should_deduct' => $money('应扣金额'), + 'deduct_start_month' => $month('分期起始扣款月份'), 'deduct_end_month' => $month('分期最后扣款月份'), + 'remain_periods' => $money('分期剩余期数'), 'month_str' => $month('借支日期'), + 'loan_term' => $text('借款期限'), 'remark' => $text('备注'), 'is_paid' => $text('是否打款'), + 'pay_month' => $month('打款月份'), 'serial_no' => $text('流水号'), + 'loan_agreement' => $text('借支协议'), 'person_charge' => $text('负责人'), + ], + 'sample-deductions' => [ + 'finish_time' => $date('日期'), 'department' => $text('部门'), 'creator' => $text('申请人'), + 'sku_id' => $text('商品代码'), 'skuName' => $text('商品名称'), 'num' => $money('数量'), + 'supplier' => $text('厂家'), 'purchase' => $text('对应采购'), 'productCode' => $text('扣款厂家商品代码'), + 'shipping_fee' => $money('运费'), 'cost_price' => $money('成本价'), + 'total_amount' => $money('合计扣款金额'), 'estimated_price' => $money('预估价'), + 'estimated_price_all' => $money('预估金额'), 'remark' => $text('备注'), + ], + 'delivery-unpriced' => [ + 'transactionNo' => $text('来源单号'), 'distributionNo' => $text('单据编号(采购单号)'), + 'date' => $date('创建时间'), 'supplier' => $text('供应商'), 'sku' => $text('货号'), + 'barCode' => $text('条码'), 'skuName' => $text('规格名'), 'supplier_barcode' => $text('供应商条码'), + 'remark' => $text('备注'), 'warehouse' => $text('仓库'), 'expressName' => $text('快递名称'), + 'expressNo' => $text('快递单号'), 'purchase' => $text('负责人'), + ], + 'other-adjustments' => [ + 'project' => $text('项目'), 'month_str' => $month('日期'), 'supplier' => $text('厂家'), + 'supplement_amount' => $money('补款金额'), 'supplement_reason' => $text('补款原因'), + 'remark' => $text('备注'), 'remark2' => $text('备注2'), 'person_charge' => $text('负责人'), + ], + 'dropshipping-express' => [ + 'reconciliation_person' => $text('对账人'), 'month_str' => $month('日期'), 'supplier' => $text('供应商'), + 'express_freight' => $money('快递运费'), 'express_account' => $money('快递记账'), + 'replenish_freight' => $money('补运费'), 'replenish_account' => $money('补记账'), + 'return_fee' => $money('退回费'), 'intercept_fee' => $money('拦截'), + 'change_address_fee' => $money('改地址'), 'turnover_fee' => $money('翻箱费'), + 'overlong_fee' => $money('超长费'), 'total_freight' => $money('合计运费'), + 'remark' => $text('备注'), 'express_name' => $text('快递'), 'remark2' => $text('备注2'), + ], + 'board-deductions' => [ + 'stock_in_date' => $date('入库日期'), 'stock_out_date' => $date('出库日期'), + 'furniture_factory' => $text('家具厂'), 'lamination_factory' => $text('压贴厂'), + 'deduction_type' => $text('类型'), 'specification' => $text('规格'), 'material_name' => $text('原料名称'), + 'received_quantity' => $money('实收数量'), 'unit_price' => $money('单价'), 'amount' => $money('金额'), + 'unit' => $text('单位'), 'batch_date' => $date('批次日期'), 'stock_out_number' => $text('出库单号'), + 'stock_out_remark' => $text('出库备注'), 'claim_number' => $text('申领单号'), + 'number_type' => $text('单号类型'), 'delivery_number' => $text('送货单号'), + ], + 'supplier-payables-report' => [ + 'checker' => $text('对账人'), 'settlement_method' => $text('结算方式'), 'company' => $text('供应商公司主体名称'), + 'supplier' => $text('供应商简称'), 'express_freight' => $money('快递运费'), + 'beginning_payable' => $money('月初未付'), 'unpriced' => $money('之前未定价-本月定价'), + 'advance_loan' => $money('预支借款'), 'loan' => $money('货款金额'), 'refund' => $money('退货金额'), + 'procurement' => $money('采购小计'), 'invoice_deduction' => $money('发票扣款'), + 'board_deduction' => $money('板材扣款'), 'sample_deduction' => $money('样品扣款'), + 'total_compensation' => $money('总赔偿金额'), 'other_adjustment' => $money('其他费用'), + 'supplier_supplement' => $money('补给厂家'), 'supplier_balance' => $money('供应商余额'), + 'should_pay' => $money('应付余额'), 'estimated_price' => $money('预估金额'), + 'to_corporate' => $money('对公打款金额'), 'to_private' => $money('对私打款金额'), + 'cashier_paid' => $money('已销账金额'), 'remarks' => $text('备注'), + 'private_reason' => $text('对私原因'), 'deposit' => $money('押款金额'), + 'negative_reason' => $text('负数原因'), + ], + default => throw ValidationException::withMessages(['dataset' => '不支持该供应商应付页面。']), + }; + } + + public function normalize(string $dataset, array $input): array + { + $fields = $this->fields($dataset); + $values = []; + + foreach ($fields as $field => $definition) { + if (! array_key_exists($field, $input)) { + continue; + } + + $value = $input[$field]; + if ($value === null || trim((string) $value) === '') { + $values[$field] = null; + continue; + } + + if ($definition['type'] === 'number') { + $number = str_replace([',', ',', '¥', '¥'], '', trim((string) $value)); + if (preg_match('/^-?(?:\d+(?:\.\d{1,4})?|\.\d{1,4})$/', $number) !== 1 + || bccomp(ltrim($number, '-'), '9999999999999999.9999', 4) === 1) { + throw ValidationException::withMessages([ + $field => $definition['label'] . '必须是最多 4 位小数的有效数字。', + ]); + } + $values[$field] = $number; + continue; + } + + $text = trim((string) $value); + if (mb_strlen($text) > 5000) { + throw ValidationException::withMessages([$field => $definition['label'] . '内容过长。']); + } + if ($definition['type'] === 'date' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $text) !== 1) { + throw ValidationException::withMessages([$field => $definition['label'] . '日期格式无效。']); + } + if ($definition['type'] === 'month' && preg_match('/^\d{4}-\d{2}$/', $text) !== 1) { + throw ValidationException::withMessages([$field => $definition['label'] . '月份格式无效。']); + } + $values[$field] = $text; + } + + if ($values === []) { + throw ValidationException::withMessages(['values' => '没有可保存的字段。']); + } + + return $values; + } + + public function publicDefinition(string $dataset): array + { + return [ + 'dataset' => $dataset, + 'fields' => collect($this->fields($dataset))->map( + static fn (array $definition, string $name): array => ['name' => $name] + $definition + )->values()->all(), + ]; + } +} diff --git a/modules/EcommerceFinance/Services/SupplierPayableOverrideStore.php b/modules/EcommerceFinance/Services/SupplierPayableOverrideStore.php new file mode 100644 index 00000000..30718531 --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierPayableOverrideStore.php @@ -0,0 +1,107 @@ +> $rows */ + public function decorate(string $dataset, array $rows, int $companyId): array + { + if ($rows === []) { + return []; + } + + $keys = []; + foreach ($rows as $row) { + $keys[] = $this->key($dataset, $row); + } + + $overrides = $this->connection()->table('supplier_payable_overrides') + ->where('company_id', $companyId) + ->where('dataset', $dataset) + ->whereIn('record_key', $keys) + ->get() + ->keyBy('record_key'); + + foreach ($rows as $index => &$row) { + $key = $keys[$index]; + $row['_action_key'] = $key; + $override = $overrides->get($key); + if ($override) { + $values = json_decode((string) $override->values, true) ?: []; + $row = array_merge($row, $values); + $row['_overridden'] = true; + } else { + $row['_overridden'] = false; + } + } + unset($row); + + return $rows; + } + + /** @return array{created:int,updated:int} */ + public function save(string $dataset, string $recordKey, array $values, int $companyId, ?int $userId, string $source = 'manual'): array + { + $connection = $this->connection(); + $existing = $connection->table('supplier_payable_overrides') + ->where(['company_id' => $companyId, 'dataset' => $dataset, 'record_key' => $recordKey]) + ->first(); + $before = $existing ? (json_decode((string) $existing->values, true) ?: []) : null; + $now = now(); + + $connection->transaction(function () use ($connection, $existing, $dataset, $recordKey, $values, $companyId, $userId, $now, $source, $before): void { + $connection->table('supplier_payable_overrides')->updateOrInsert( + ['company_id' => $companyId, 'dataset' => $dataset, 'record_key' => $recordKey], + [ + 'values' => json_encode($values, JSON_UNESCAPED_UNICODE), + 'updated_by' => $userId, + 'created_by' => $existing->created_by ?? $userId, + 'created_at' => $existing->created_at ?? $now, + 'updated_at' => $now, + ], + ); + $connection->table('supplier_payable_override_events')->insert([ + 'company_id' => $companyId, + 'dataset' => $dataset, + 'record_key' => $recordKey, + 'source' => $source, + 'before_values' => $before === null ? null : json_encode($before, JSON_UNESCAPED_UNICODE), + 'after_values' => json_encode($values, JSON_UNESCAPED_UNICODE), + 'user_id' => $userId, + 'created_at' => $now, + ]); + }); + + return ['created' => $existing ? 0 : 1, 'updated' => $existing ? 1 : 0]; + } + + public function transaction(Closure $callback): mixed + { + return $this->connection()->transaction($callback); + } + + private function connection(): ConnectionInterface + { + return DB::connection(config('database.supplier_payable_maintenance_connection')); + } +} diff --git a/modules/EcommerceFinance/Services/SupplierPayableWorkbookImporter.php b/modules/EcommerceFinance/Services/SupplierPayableWorkbookImporter.php new file mode 100644 index 00000000..727e90cc --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierPayableWorkbookImporter.php @@ -0,0 +1,129 @@ +registry->fields($dataset); + $lookup = ['记录标识' => '_action_key', 'recordkey' => '_action_key', '_action_key' => '_action_key']; + foreach ($definitions as $field => $definition) { + $lookup[$this->header($field)] = $field; + $lookup[$this->header($definition['label'])] = $field; + } + + try { + $readerType = match (mb_strtolower($file->getClientOriginalExtension())) { + 'xlsx' => 'Xlsx', + 'xls' => 'Xls', + 'csv' => 'Csv', + default => throw new \RuntimeException('Unsupported workbook format.'), + }; + $spreadsheet = IOFactory::createReader($readerType)->load($file->getRealPath()); + [$rows, $headerRow, $columns] = $this->bestSheet($spreadsheet, $lookup); + } catch (Throwable) { + throw ValidationException::withMessages([ + 'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。', + ]); + } finally { + if (isset($spreadsheet)) { + $spreadsheet->disconnectWorksheets(); + } + } + + if ($rows === null || ! in_array('_action_key', $columns, true)) { + throw ValidationException::withMessages([ + 'import' => '未找到“记录标识”列,请先导出当前数据,再使用导出的表格修改。', + ]); + } + + $records = []; + foreach (array_slice($rows, $headerRow + 1, null, true) as $rowIndex => $row) { + $line = $rowIndex + 1; + $key = ''; + $rawValues = []; + foreach ($columns as $columnIndex => $field) { + if ($field === '_action_key') { + $key = trim((string) ($row[$columnIndex] ?? '')); + } else { + $rawValues[$field] = $row[$columnIndex] ?? null; + } + } + if ($key === '' && collect($rawValues)->every(static fn (mixed $value): bool => trim((string) $value) === '')) { + continue; + } + if (! str_starts_with($key, 'id:') && ! preg_match('/^hash:[a-f0-9]{64}$/', $key)) { + throw ValidationException::withMessages(['import' => "第 {$line} 行“记录标识”无效,请勿修改该列。"]); + } + if (isset($records[$key])) { + throw ValidationException::withMessages(['import' => "第 {$line} 行“记录标识”重复。"]); + } + try { + $records[$key] = $this->registry->normalize($dataset, $rawValues); + } catch (ValidationException $exception) { + throw ValidationException::withMessages([ + 'import' => "第 {$line} 行:" . collect($exception->errors())->flatten()->first(), + ]); + } + if (count($records) > 10000) { + throw ValidationException::withMessages(['import' => '单次最多导入 10,000 条记录,请拆分文件后重试。']); + } + } + + if ($records === []) { + throw ValidationException::withMessages(['import' => '表格中没有可导入的数据行。']); + } + + $counts = ['created' => 0, 'updated' => 0]; + $this->overrides->transaction(function () use ($records, $dataset, $companyId, $userId, &$counts): void { + foreach ($records as $key => $values) { + $result = $this->overrides->save($dataset, $key, $values, $companyId, $userId, 'import'); + $counts['created'] += $result['created']; + $counts['updated'] += $result['updated']; + } + }); + + return $counts + ['rows' => count($records)]; + } + + private function bestSheet(mixed $spreadsheet, array $lookup): array + { + $best = [null, null, []]; + $score = 0; + foreach ($spreadsheet->getWorksheetIterator() as $worksheet) { + $rows = $worksheet->toArray(null, false, false, false); + foreach (array_slice($rows, 0, 20, true) as $rowIndex => $row) { + $columns = []; + foreach ($row as $columnIndex => $heading) { + $key = $this->header($heading); + if ($key !== '' && isset($lookup[$key])) { + $columns[$columnIndex] = $lookup[$key]; + } + } + if (count($columns) > $score) { + $best = [$rows, $rowIndex, $columns]; + $score = count($columns); + } + } + } + return $best; + } + + private function header(mixed $value): string + { + return mb_strtolower(preg_replace('/[\s\x{FEFF}_\-()()]+/u', '', trim((string) $value))); + } +} diff --git a/resources/assets/js/components/business/AccountBills.vue b/resources/assets/js/components/business/AccountBills.vue index c3377271..9d04b62a 100644 --- a/resources/assets/js/components/business/AccountBills.vue +++ b/resources/assets/js/components/business/AccountBills.vue @@ -16,19 +16,19 @@ -
+
{{ ['jd-transactions','jd-wallet'].includes(billType) ? '费用结算时间' : '发生时间' }}
- + - - + + 至 - + - - + +
@@ -81,6 +81,7 @@ export default { data(){return{loading:false,loadError:false,sync:null,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:emptyFilters(this.billType),queryFilters:emptyFilters(this.billType),resultFilters:null,filterOptions:{shops:[],bill_items:[]},filterOptionsLoading:false,filterOptionsError:false,requestSerial:0,sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible'};}, computed:{ isDouyin(){return this.billType==='douyin';}, + usesStableDateControl(){return ['pinduoduo','dewu'].indexOf(this.billType)!==-1;}, usesMirrorOptions(){return ['douyin','pinduoduo','dewu','jd-transactions','jd-wallet'].indexOf(this.billType)!==-1;}, invalidDateRange(){return Boolean(this.filters.start&&this.filters.end&&this.filters.start>this.filters.end);}, startDateConfig(){return {...dateConfig,maxDate:this.filters.end||null};}, @@ -129,6 +130,12 @@ export default { .tw-date-picker{flex:none} .tw-date-clear{display:flex;align-items:center;justify-content:center;width:1.5rem;flex:none;padding:0;border:1px solid #d8d2c7;border-left:0;border-right:0;background:#fff;color:#595959;cursor:pointer} .tw-date-clear .material-icons-outlined{font-size:1rem} +.tw-date.is-stable-date .tw-date-control{position:relative;overflow:hidden;height:2.125rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff;cursor:pointer} +.tw-date.is-stable-date .tw-date-control:focus-within{border-color:#856632;box-shadow:0 0 0 3px rgba(133,102,50,.2)} +.tw-date.is-stable-date .tw-date-control.has-picker input{width:100%;height:100%;padding:0 4rem 0 .625rem;border:0;border-radius:0;background:transparent;box-shadow:none;cursor:pointer} +.tw-date.is-stable-date .tw-date-picker,.tw-date.is-stable-date .tw-date-clear{position:absolute;top:0;height:2rem;border-top:0;border-bottom:0} +.tw-date.is-stable-date .tw-date-picker{right:0;border-right:0;border-radius:0} +.tw-date.is-stable-date .tw-date-clear{right:2.125rem;border-left:0;z-index:1} @container(max-width:1200px){ .table-workspace .tw-filter{grid-template-columns:repeat(2,minmax(0,1fr))} .table-workspace .tw-date{grid-column:1/-1} diff --git a/resources/assets/js/views/business/pages.js b/resources/assets/js/views/business/pages.js index 33beb2b7..7576d55c 100644 --- a/resources/assets/js/views/business/pages.js +++ b/resources/assets/js/views/business/pages.js @@ -16,8 +16,8 @@ import Vue from 'vue'; import TaobaoAlipayBills from './../../components/business/TaobaoAlipayBills.vue'; import AccountBills from './../../components/business/AccountBills.vue'; -const GENERIC_TABS = ['taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'douyin', 'dewu']; -const STORE_RECEIVING_TABS = ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'douyin', 'dewu']; +const GENERIC_TABS = ['taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'douyin', 'dewu', 'vipshop']; +const STORE_RECEIVING_TABS = ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'douyin', 'dewu', 'vipshop']; function pageContext(pathname = window.location.pathname) { const parts = (pathname || '').split('/').filter(Boolean); diff --git a/resources/assets/js/views/ecommerce-finance/advance-details.js b/resources/assets/js/views/ecommerce-finance/advance-details.js index d6629fba..07bec1b8 100644 --- a/resources/assets/js/views/ecommerce-finance/advance-details.js +++ b/resources/assets/js/views/ecommerce-finance/advance-details.js @@ -3,6 +3,7 @@ require('./../../bootstrap'); import Vue from 'vue'; import DashboardPlugin from './../../plugins/dashboard-plugin'; import Global from './../../mixins/global'; +import SupplierPayableMaintenance from './supplier-payable-maintenance'; import { Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message @@ -21,10 +22,11 @@ Vue.prototype.$message = Message; const app = new Vue({ el: '#advance-detail-app', - mixins: [Global], + mixins: [Global, SupplierPayableMaintenance], data() { return { + maintenanceDataset: 'advance-details', baseUrl: url + '/workspace-data/supplier-payables/advance-details', loading: false, tableData: [], diff --git a/resources/assets/js/views/ecommerce-finance/compensation-details.js b/resources/assets/js/views/ecommerce-finance/compensation-details.js index ae67fbbb..5d11d8eb 100644 --- a/resources/assets/js/views/ecommerce-finance/compensation-details.js +++ b/resources/assets/js/views/ecommerce-finance/compensation-details.js @@ -3,6 +3,7 @@ require('./../../bootstrap'); import Vue from 'vue'; import DashboardPlugin from './../../plugins/dashboard-plugin'; import Global from './../../mixins/global'; +import SupplierPayableMaintenance from './supplier-payable-maintenance'; import { Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message @@ -21,10 +22,11 @@ Vue.prototype.$message = Message; const app = new Vue({ el: '#compensation-detail-app', - mixins: [Global], + mixins: [Global, SupplierPayableMaintenance], data() { return { + maintenanceDataset: 'compensation-details', baseUrl: url + '/workspace-data/supplier-payables/compensation-details', loading: false, tableData: [], diff --git a/resources/assets/js/views/ecommerce-finance/delivery-unpriced.js b/resources/assets/js/views/ecommerce-finance/delivery-unpriced.js index c5347cd6..4ad90b17 100644 --- a/resources/assets/js/views/ecommerce-finance/delivery-unpriced.js +++ b/resources/assets/js/views/ecommerce-finance/delivery-unpriced.js @@ -3,6 +3,7 @@ require('./../../bootstrap'); import Vue from 'vue'; import DashboardPlugin from './../../plugins/dashboard-plugin'; import Global from './../../mixins/global'; +import SupplierPayableMaintenance from './supplier-payable-maintenance'; import { Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Select, Option, Message @@ -23,10 +24,11 @@ Vue.prototype.$message = Message; const app = new Vue({ el: '#delivery-unpriced-app', - mixins: [Global], + mixins: [Global, SupplierPayableMaintenance], data() { return { + maintenanceDataset: 'delivery-unpriced', baseUrl: url + '/workspace-data/supplier-payables/delivery-unpriced', loading: false, tableData: [], diff --git a/resources/assets/js/views/ecommerce-finance/dropshipping-express.js b/resources/assets/js/views/ecommerce-finance/dropshipping-express.js index 8b022f91..daf41317 100644 --- a/resources/assets/js/views/ecommerce-finance/dropshipping-express.js +++ b/resources/assets/js/views/ecommerce-finance/dropshipping-express.js @@ -3,6 +3,7 @@ require('./../../bootstrap'); import Vue from 'vue'; import DashboardPlugin from './../../plugins/dashboard-plugin'; import Global from './../../mixins/global'; +import SupplierPayableMaintenance from './supplier-payable-maintenance'; import { Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Select, Option, Message @@ -23,10 +24,11 @@ Vue.prototype.$message = Message; const app = new Vue({ el: '#dropshipping-express-app', - mixins: [Global], + mixins: [Global, SupplierPayableMaintenance], data() { return { + maintenanceDataset: 'dropshipping-express', baseUrl: url + '/workspace-data/supplier-payables/dropshipping-express', loading: false, tableData: [], diff --git a/resources/assets/js/views/ecommerce-finance/goods-reconciliations.js b/resources/assets/js/views/ecommerce-finance/goods-reconciliations.js index 28cefa94..43c3166b 100644 --- a/resources/assets/js/views/ecommerce-finance/goods-reconciliations.js +++ b/resources/assets/js/views/ecommerce-finance/goods-reconciliations.js @@ -3,6 +3,7 @@ require('./../../bootstrap'); import Vue from 'vue'; import DashboardPlugin from './../../plugins/dashboard-plugin'; import Global from './../../mixins/global'; +import SupplierPayableMaintenance from './supplier-payable-maintenance'; import { Table, @@ -24,10 +25,11 @@ Vue.prototype.$message = Message; const app = new Vue({ el: '#goods-reconciliation-app', - mixins: [Global], + mixins: [Global, SupplierPayableMaintenance], data() { return { + maintenanceDataset: 'payment-reconciliation', baseUrl: url + '/workspace-data/supplier-payables/payment-reconciliation', loading: false, tableData: [], @@ -234,6 +236,17 @@ const app = new Vue({ this.fetchData(); }, + onReset() { + this.filters = { + record_type: 'all', supplier: '', warehouse: '', stock_no: '', purchase_no: '', + product_code: '', express_no: '', send_time_range: null, create_time_range: null, + sent_status: 'all', has_diff: false + }; + this.activeTab = 'all'; + this.currentPage = 1; + this.fetchData(); + }, + /** * Pagination page change */ diff --git a/resources/assets/js/views/ecommerce-finance/invoice-deductions.js b/resources/assets/js/views/ecommerce-finance/invoice-deductions.js index a959757b..d7197a94 100644 --- a/resources/assets/js/views/ecommerce-finance/invoice-deductions.js +++ b/resources/assets/js/views/ecommerce-finance/invoice-deductions.js @@ -3,6 +3,7 @@ require('./../../bootstrap'); import Vue from 'vue'; import DashboardPlugin from './../../plugins/dashboard-plugin'; import Global from './../../mixins/global'; +import SupplierPayableMaintenance from './supplier-payable-maintenance'; import { Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message @@ -21,10 +22,11 @@ Vue.prototype.$message = Message; const app = new Vue({ el: '#invoice-deduction-app', - mixins: [Global], + mixins: [Global, SupplierPayableMaintenance], data() { return { + maintenanceDataset: 'invoice-deductions', baseUrl: url + '/workspace-data/supplier-payables/invoice-deductions', loading: false, tableData: [], diff --git a/resources/assets/js/views/ecommerce-finance/other-adjustments.js b/resources/assets/js/views/ecommerce-finance/other-adjustments.js index 3108b4d0..aecdfef4 100644 --- a/resources/assets/js/views/ecommerce-finance/other-adjustments.js +++ b/resources/assets/js/views/ecommerce-finance/other-adjustments.js @@ -3,6 +3,7 @@ require('./../../bootstrap'); import Vue from 'vue'; import DashboardPlugin from './../../plugins/dashboard-plugin'; import Global from './../../mixins/global'; +import SupplierPayableMaintenance from './supplier-payable-maintenance'; import { Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message @@ -21,10 +22,11 @@ Vue.prototype.$message = Message; const app = new Vue({ el: '#other-adjustment-app', - mixins: [Global], + mixins: [Global, SupplierPayableMaintenance], data() { return { + maintenanceDataset: 'other-adjustments', baseUrl: url + '/workspace-data/supplier-payables/other-adjustments', loading: false, tableData: [], diff --git a/resources/assets/js/views/ecommerce-finance/return-reconciliations.js b/resources/assets/js/views/ecommerce-finance/return-reconciliations.js index 7f193bfa..76ab3686 100644 --- a/resources/assets/js/views/ecommerce-finance/return-reconciliations.js +++ b/resources/assets/js/views/ecommerce-finance/return-reconciliations.js @@ -3,6 +3,7 @@ require('./../../bootstrap'); import Vue from 'vue'; import DashboardPlugin from './../../plugins/dashboard-plugin'; import Global from './../../mixins/global'; +import SupplierPayableMaintenance from './supplier-payable-maintenance'; import { Table, @@ -32,10 +33,11 @@ Vue.prototype.$message = Message; const app = new Vue({ el: '#return-reconciliation-app', - mixins: [Global], + mixins: [Global, SupplierPayableMaintenance], data() { return { + maintenanceDataset: 'return-reconciliation', baseUrl: url + '/workspace-data/supplier-payables/return-reconciliation', loading: false, tableData: [], diff --git a/resources/assets/js/views/ecommerce-finance/return-unpriced.js b/resources/assets/js/views/ecommerce-finance/return-unpriced.js index 44293c4c..13f56613 100644 --- a/resources/assets/js/views/ecommerce-finance/return-unpriced.js +++ b/resources/assets/js/views/ecommerce-finance/return-unpriced.js @@ -3,6 +3,7 @@ require('./../../bootstrap'); import Vue from 'vue'; import DashboardPlugin from './../../plugins/dashboard-plugin'; import Global from './../../mixins/global'; +import SupplierPayableMaintenance from './supplier-payable-maintenance'; import { Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Select, Option, Message @@ -23,10 +24,11 @@ Vue.prototype.$message = Message; const app = new Vue({ el: '#return-unpriced-app', - mixins: [Global], + mixins: [Global, SupplierPayableMaintenance], data() { return { + maintenanceDataset: 'return-unpriced', baseUrl: url + '/workspace-data/supplier-payables/return-unpriced', loading: false, tableData: [], diff --git a/resources/assets/js/views/ecommerce-finance/sample-deductions.js b/resources/assets/js/views/ecommerce-finance/sample-deductions.js index 21e07f49..c5c0e436 100644 --- a/resources/assets/js/views/ecommerce-finance/sample-deductions.js +++ b/resources/assets/js/views/ecommerce-finance/sample-deductions.js @@ -3,6 +3,7 @@ require('./../../bootstrap'); import Vue from 'vue'; import DashboardPlugin from './../../plugins/dashboard-plugin'; import Global from './../../mixins/global'; +import SupplierPayableMaintenance from './supplier-payable-maintenance'; import { Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message @@ -21,10 +22,11 @@ Vue.prototype.$message = Message; const app = new Vue({ el: '#sample-deduction-app', - mixins: [Global], + mixins: [Global, SupplierPayableMaintenance], data() { return { + maintenanceDataset: 'sample-deductions', baseUrl: url + '/workspace-data/supplier-payables/sample-deductions', loading: false, tableData: [], diff --git a/resources/assets/js/views/ecommerce-finance/supplier-payable-maintenance.js b/resources/assets/js/views/ecommerce-finance/supplier-payable-maintenance.js new file mode 100644 index 00000000..e60dc514 --- /dev/null +++ b/resources/assets/js/views/ecommerce-finance/supplier-payable-maintenance.js @@ -0,0 +1,166 @@ +import Vue from 'vue'; +import { Dialog, Form, FormItem, Input, DatePicker, Button } from 'element-ui'; + +[Dialog, Form, FormItem, Input, DatePicker, Button].forEach(component => Vue.component(component.name, component)); + +export default { + data() { + return { + maintenance: { + fields: [], + form: {}, + row: null, + selectedRow: null, + visible: false, + saving: false, + importing: false, + importVisible: false, + importFile: null, + importFileName: '' + } + }; + }, + + computed: { + maintenanceBaseUrl() { + return url + '/workspace/supplier-payables/actions/' + this.maintenanceDataset; + } + }, + + watch: { + tableData() { + this.maintenanceSelect(null); + } + }, + + mounted() { + axios.get(this.maintenanceBaseUrl + '/definition') + .then(response => { this.maintenance.fields = response.data.fields || []; }) + .catch(() => this.$message.error('获取维护字段失败')); + }, + + methods: { + maintenanceSelect(row) { + this.maintenance.selectedRow = row || null; + }, + + maintenanceEditSelected() { + const row = this.maintenance.selectedRow; + if (!row || !row._action_key) { + this.$message.warning('请先选择一条记录'); + return; + } + if (!this.maintenance.fields.length) { + this.$message.warning('编辑字段正在加载,请稍后重试'); + return; + } + this.maintenanceOpen(row); + }, + + maintenanceOpen(row) { + if (!row || !row._action_key || !this.maintenance.fields.length) return; + const form = {}; + this.maintenance.fields.forEach(field => { + const value = row[field.name]; + form[field.name] = value == null ? '' : String(value); + }); + this.maintenance.row = row; + this.maintenance.form = form; + this.maintenance.visible = true; + }, + + maintenanceClose() { + this.maintenance.row = null; + this.maintenance.form = {}; + this.maintenance.visible = false; + this.maintenance.saving = false; + }, + + maintenanceSave() { + if (!this.maintenance.row || !this.maintenance.row._action_key) { + this.$message.error('当前记录缺少维护标识,请刷新后重试'); + return; + } + this.maintenance.saving = true; + axios.patch( + this.maintenanceBaseUrl + '/' + this.maintenanceRecordKey(this.maintenance.row._action_key), + { values: this.maintenance.form }, + { headers: { Accept: 'application/json', 'Content-Type': 'application/json' } } + ).then(response => { + this.$message.success(response.data.message || '保存成功'); + this.maintenance.visible = false; + this.fetchData(); + }).catch(error => this.$message.error(this.maintenanceError(error, '保存失败'))) + .finally(() => { this.maintenance.saving = false; }); + }, + + maintenanceRecordKey(recordKey) { + return String(recordKey).split(':').map(encodeURIComponent).join(':'); + }, + + maintenanceOpenImport() { + this.maintenance.importFile = null; + this.maintenance.importFileName = ''; + this.maintenance.importVisible = true; + }, + + maintenanceCloseImport() { + if (this.maintenance.importing) return; + this.maintenance.importVisible = false; + this.maintenance.importFile = null; + this.maintenance.importFileName = ''; + if (this.$refs.maintenanceImport) this.$refs.maintenanceImport.value = ''; + }, + + maintenanceChooseImport() { + this.$refs.maintenanceImport.click(); + }, + + maintenanceSelectImport(event) { + const file = event.target.files && event.target.files[0]; + if (!file) return; + this.maintenance.importFile = file; + this.maintenance.importFileName = file.name; + }, + + maintenanceImport() { + const file = this.maintenance.importFile; + if (!file) { + this.$message.warning('请先选择导入文件'); + return; + } + + const body = new FormData(); + body.append('import', file); + this.maintenance.importing = true; + axios.post(this.maintenanceBaseUrl + '/import', body, { + headers: { Accept: 'application/json', 'Content-Type': 'multipart/form-data' } + }).then(response => { + this.$message.success(response.data.message || '导入完成'); + this.maintenance.importVisible = false; + this.maintenance.importFile = null; + this.maintenance.importFileName = ''; + if (this.$refs.maintenanceImport) this.$refs.maintenanceImport.value = ''; + this.fetchData(); + }).catch(error => this.$message.error(this.maintenanceError(error, '导入失败'))) + .finally(() => { this.maintenance.importing = false; }); + }, + + maintenanceExport() { + const params = Object.assign({}, this.buildParams ? this.buildParams() : {}); + delete params.page; + delete params.limit; + const query = new URLSearchParams(params).toString(); + window.location.href = this.maintenanceBaseUrl + '/export' + (query ? '?' + query : ''); + }, + + maintenanceError(error, fallback) { + const errors = error && error.response && error.response.data && error.response.data.errors; + if (errors) { + const first = Object.values(errors)[0]; + if (Array.isArray(first) && first[0]) return first[0]; + } + return (error && error.response && error.response.data && error.response.data.message) || fallback; + } + } +}; diff --git a/resources/views/business/pages/courier-payables/agreement-signing.blade.php b/resources/views/business/pages/courier-payables/agreement-signing.blade.php index 8db7a0f6..1bd3aabe 100644 --- a/resources/views/business/pages/courier-payables/agreement-signing.blade.php +++ b/resources/views/business/pages/courier-payables/agreement-signing.blade.php @@ -9,6 +9,10 @@ @php($agreementCompanyName = $agreementDefaultSubject?->name ?: company()?->name ?: '当前公司') @php($agreementSubjectPayload = $agreementSubjects->map(fn ($subject) => ['id' => $subject->id, 'name' => $subject->name, 'unified_social_credit_code' => $subject->unified_social_credit_code, 'is_default' => (bool) $subject->is_default])->values()) @php($esignConfigured = \App\Services\Esign\EsignClient::configured()) +@php($agreementSearch = trim((string) request('agreement_search', ''))) +@php($agreementStatus = trim((string) request('agreement_status', ''))) +@php($agreementType = trim((string) request('agreement_type', ''))) +@php($agreementFiltersActive = $agreementSearch !== '' || $agreementStatus !== '' || $agreementType !== '')
{{ $ready ? '正式维护数据' : '数据表尚未就绪' }} {{ $ready ? '报价规则已保存到当前公司数据表,刷新页面后仍会保留;后续结算页面将按启用中的规则计算。' : ($dataReady && !$auditReady ? '操作日志表尚未完成迁移;现有报价仍可查看,新增、编辑和启停暂时禁用,避免产生无日志修改。' : '附加费报价数据表尚未完成迁移,当前不能保存规则。请由管理员在目标数据库完成迁移后再使用本页。') }}
@@ -88,7 +121,7 @@
-
重置
+
重置
@@ -117,7 +150,7 @@
没有符合条件的报价规则,请点击“新增报价规则”。
@endforelse -
维护建议:固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。当前筛选结果可导出为 CSV;本页暂未启用批量导入,避免在未确认字段规则前覆盖维护数据。
+
维护建议:固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。批量导入必须先预览校验,已有规则需明确选择跳过或更新。
@@ -146,6 +179,61 @@ @endif
+ + +@include('business.pages.supplier-payables.maintenance-blade-modal') diff --git a/resources/views/business/pages/supplier-payables/compensation-details.blade.php b/resources/views/business/pages/supplier-payables/compensation-details.blade.php index ddfce9dd..030e290d 100644 --- a/resources/views/business/pages/supplier-payables/compensation-details.blade.php +++ b/resources/views/business/pages/supplier-payables/compensation-details.blade.php @@ -46,8 +46,7 @@ start-placeholder="月份开始" end-placeholder="月份结束" size="small" style="width: 240px" value-format="yyyy-MM" @change="onFilterChange"> - 查询 - 重置 + @include('business.pages.supplier-payables.maintenance-actions') @@ -56,7 +55,7 @@
加载中...
- + diff --git a/resources/views/business/pages/supplier-payables/delivery-unpriced.blade.php b/resources/views/business/pages/supplier-payables/delivery-unpriced.blade.php index adee8b68..46fb957e 100644 --- a/resources/views/business/pages/supplier-payables/delivery-unpriced.blade.php +++ b/resources/views/business/pages/supplier-payables/delivery-unpriced.blade.php @@ -1,7 +1,7 @@
{{-- 统计卡片 --}} -
+
未定价记录数
@{{ (summary.totalCount || 0).toLocaleString() }}
@@ -29,8 +29,7 @@ start-placeholder="日期开始" end-placeholder="日期结束" size="small" style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange"> - 查询 - 重置 + @include('business.pages.supplier-payables.maintenance-actions')
@@ -39,7 +38,7 @@
加载中...
- + diff --git a/resources/views/business/pages/supplier-payables/dropshipping-express.blade.php b/resources/views/business/pages/supplier-payables/dropshipping-express.blade.php index 692745e2..86daeccd 100644 --- a/resources/views/business/pages/supplier-payables/dropshipping-express.blade.php +++ b/resources/views/business/pages/supplier-payables/dropshipping-express.blade.php @@ -1,7 +1,7 @@
{{-- 统计卡片 --}} -
+
合计运费
@{{ formatMoney(summary.totalAmount) }}
@@ -23,8 +23,7 @@ start-placeholder="月份开始" end-placeholder="月份结束" size="small" style="width: 240px" value-format="yyyy-MM" @change="onFilterChange"> - 查询 - 重置 + @include('business.pages.supplier-payables.maintenance-actions')
@@ -33,7 +32,7 @@
加载中...
- + diff --git a/resources/views/business/pages/supplier-payables/invoice-deductions.blade.php b/resources/views/business/pages/supplier-payables/invoice-deductions.blade.php index a7cd58eb..2fc0d9dd 100644 --- a/resources/views/business/pages/supplier-payables/invoice-deductions.blade.php +++ b/resources/views/business/pages/supplier-payables/invoice-deductions.blade.php @@ -1,7 +1,7 @@
{{-- 统计卡片 --}} -
+
扣款总金额
@{{ formatMoney(summary.totalPrice) }}
@@ -20,8 +20,7 @@ start-placeholder="月份开始" end-placeholder="月份结束" size="small" style="width: 240px" value-format="yyyy-MM" @change="onFilterChange"> - 查询 - 重置 + @include('business.pages.supplier-payables.maintenance-actions')
@@ -30,7 +29,7 @@
加载中...
- + diff --git a/resources/views/business/pages/supplier-payables/maintenance-actions.blade.php b/resources/views/business/pages/supplier-payables/maintenance-actions.blade.php new file mode 100644 index 00000000..3b72a117 --- /dev/null +++ b/resources/views/business/pages/supplier-payables/maintenance-actions.blade.php @@ -0,0 +1,10 @@ +
+ @include('business.pages.supplier-payables.maintenance-toolbar') + + 查询 + + + 重置 + + @include('business.pages.supplier-payables.maintenance-edit-button') +
diff --git a/resources/views/business/pages/supplier-payables/maintenance-blade-modal.blade.php b/resources/views/business/pages/supplier-payables/maintenance-blade-modal.blade.php new file mode 100644 index 00000000..26e7dee2 --- /dev/null +++ b/resources/views/business/pages/supplier-payables/maintenance-blade-modal.blade.php @@ -0,0 +1,109 @@ +@php + $maintenance = $content['supplierPayableMaintenance'] ?? ['dataset' => '', 'fields' => []]; + $maintenanceAction = url('/' . (company_id() ?: 1) . '/workspace/supplier-payables/actions/' . $maintenance['dataset']); +@endphp + + + +@once + +@endonce + +@push('body_end') + +@endpush diff --git a/resources/views/business/pages/supplier-payables/maintenance-edit-button.blade.php b/resources/views/business/pages/supplier-payables/maintenance-edit-button.blade.php new file mode 100644 index 00000000..2f9eb8dc --- /dev/null +++ b/resources/views/business/pages/supplier-payables/maintenance-edit-button.blade.php @@ -0,0 +1,6 @@ + + 编辑 + diff --git a/resources/views/business/pages/supplier-payables/maintenance-import-modal.blade.php b/resources/views/business/pages/supplier-payables/maintenance-import-modal.blade.php new file mode 100644 index 00000000..1dbe9a30 --- /dev/null +++ b/resources/views/business/pages/supplier-payables/maintenance-import-modal.blade.php @@ -0,0 +1,96 @@ + + +@once + +@endonce + +@push('body_end') + +@endpush diff --git a/resources/views/business/pages/supplier-payables/maintenance-toolbar.blade.php b/resources/views/business/pages/supplier-payables/maintenance-toolbar.blade.php new file mode 100644 index 00000000..35d7e671 --- /dev/null +++ b/resources/views/business/pages/supplier-payables/maintenance-toolbar.blade.php @@ -0,0 +1,43 @@ + + 导入 + + + 导出 + + + +
+ + + 下载导入模板当前页面字段模板(Excel) + + + +
+ + 取消 + 开始导入 + +
+ + + + + + + + + + + 取消 + 保存 + + diff --git a/resources/views/business/pages/supplier-payables/navigation.blade.php b/resources/views/business/pages/supplier-payables/navigation.blade.php index ef119914..3fece263 100644 --- a/resources/views/business/pages/supplier-payables/navigation.blade.php +++ b/resources/views/business/pages/supplier-payables/navigation.blade.php @@ -33,5 +33,106 @@ .supplier-page-navigation { display:flex; flex-wrap:wrap; gap:4px 16px; margin-bottom:16px; } .supplier-page-navigation .business-tab-link { flex:0 0 auto; padding:10px 0; font-size:0.75rem; color:var(--vip-content-muted, #595959); } .supplier-page-navigation .business-tab-link.is-active { color:var(--vip-content-accent-dark, #856632); } + .supplier-maintenance-file { display: none; } + .supplier-maintenance-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 1rem; + max-height: 60vh; + overflow-y: auto; + padding-right: 0.25rem; + } + .supplier-maintenance-form .el-form-item { margin-bottom: 0.75rem; } + .supplier-maintenance-form .el-form-item__label { padding: 0; color: #595959; font-size: 0.75rem; } + .supplier-maintenance-form input { letter-spacing: 0; } + .supplier-maintenance-form input:focus { border-color: #856632; box-shadow: 0 0 0 3px rgba(133, 102, 50, .20); } + .supplier-maintenance-form + * .el-button--primary, + .el-dialog__footer .el-button--primary { border-color: #856632; background: #856632; } + .supplier-import-dialog { display: grid; gap: 12px; } + .supplier-import-template-link, + .supplier-import-file-button { + display: flex; + width: 100%; + min-height: 64px; + box-sizing: border-box; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid #D8D2C7; + border-radius: 8px; + background: #fff; + color: #171717; + text-align: left; + text-decoration: none; + cursor: pointer; + } + .supplier-import-template-link:hover, + .supplier-import-template-link:focus, + .supplier-import-file-button:hover, + .supplier-import-file-button:focus { border-color: #856632; background: #F7F0DE; color: #856632; outline: 0; } + .supplier-import-template-link .material-icons-outlined, + .supplier-import-file-button .material-icons-outlined { flex: 0 0 auto; font-size: 22px; } + .supplier-import-template-link > span:last-child { display: grid; gap: 3px; } + .supplier-import-template-link strong { font-size: .875rem; font-weight: 600; } + .supplier-import-template-link small { color: #737373; font-size: .75rem; } + .supplier-import-file-button > span:last-child { min-width: 0; overflow: hidden; font-size: .8125rem; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } + .supplier-page-actions { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + } + .supplier-page-actions .supplier-action-button { + display: inline-flex; + box-sizing: border-box; + min-width: 82px; + height: 36px; + margin: 0; + padding: 0 14px; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px solid var(--vip-border, #D8D2C7); + border-radius: 8px; + background: #fff; + color: #171717; + font-size: 0.8125rem; + font-weight: 600; + line-height: 1; + letter-spacing: 0; + text-decoration: none; + white-space: nowrap; + cursor: pointer; + } + .supplier-page-actions .supplier-action-button > span, + .supplier-page-actions .supplier-action-button.el-button > span { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + } + .supplier-page-actions .supplier-action-button .material-icons-outlined { + font-size: 18px; + line-height: 1; + } + .supplier-page-actions .supplier-action-button:hover, + .supplier-page-actions .supplier-action-button:focus { + border-color: var(--vip-content-accent-dark, #856632); + background: var(--vip-content-accent-soft, #F7F0DE); + color: var(--vip-content-accent-dark, #856632); + outline: 0; + } + .supplier-page-actions .supplier-action-button.is-primary { + border-color: var(--vip-content-accent-dark, #856632); + background: var(--vip-content-accent-dark, #856632); + color: #fff; + } + .supplier-page-actions .supplier-action-button.is-primary:hover, + .supplier-page-actions .supplier-action-button.is-primary:focus { + border-color: #6f5428; + background: #6f5428; + color: #fff; + } + @media (max-width: 640px) { .supplier-maintenance-form { grid-template-columns: 1fr; } } @include('business.pages.supplier-payables.local-navigation') diff --git a/resources/views/business/pages/supplier-payables/other-adjustments.blade.php b/resources/views/business/pages/supplier-payables/other-adjustments.blade.php index 953ddea8..3faea24b 100644 --- a/resources/views/business/pages/supplier-payables/other-adjustments.blade.php +++ b/resources/views/business/pages/supplier-payables/other-adjustments.blade.php @@ -1,7 +1,7 @@
{{-- 统计卡片 --}} -
+
补款总金额
@{{ formatMoney(summary.totalAmount) }}
@@ -20,8 +20,7 @@ start-placeholder="月份开始" end-placeholder="月份结束" size="small" style="width: 240px" value-format="yyyy-MM" @change="onFilterChange"> - 查询 - 重置 + @include('business.pages.supplier-payables.maintenance-actions')
@@ -30,7 +29,7 @@
加载中...
- +