Merge remote-tracking branch 'origin/main' into fix/chenxi-login-redirect

This commit is contained in:
chenxi 2026-09-22 22:30:05 +08:00
commit d36b46007f
123 changed files with 9635 additions and 420 deletions

30
.env.jushuitan.example Normal file
View File

@ -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

View File

@ -156,6 +156,43 @@ class CourierSurchargeQuoteRepository
return $this->types->activeNames();
}
/** @param array<string,mixed> $data */
public function findImportDuplicate(array $data, bool $lockForUpdate = false): ?CourierSurchargeQuote
{
$query = CourierSurchargeQuote::query()
->where('company_id', company_id())
->where('courier', trim((string) $data['courier']))
->where('scope', trim((string) $data['scope']))
->where('reconciler', trim((string) $data['reconciler']))
->where('mode', trim((string) $data['mode']))
->where('condition', trim((string) $data['condition']))
->whereDate('effective_at', (string) $data['effective_at']);
if ($lockForUpdate) {
$query->lockForUpdate();
}
$types = $this->normalizedTypes((array) $data['types']);
return $query->get()->first(
fn (CourierSurchargeQuote $quote): bool => $this->normalizedTypes((array) $quote->types) === $types,
);
}
/** @param array<string,mixed> $data */
public function importIdentity(array $data): string
{
return hash('sha256', json_encode([
trim((string) ($data['courier'] ?? '')),
trim((string) ($data['scope'] ?? '')),
trim((string) ($data['reconciler'] ?? '')),
$this->normalizedTypes((array) ($data['types'] ?? [])),
trim((string) ($data['mode'] ?? '')),
trim((string) ($data['effective_at'] ?? '')),
trim((string) ($data['condition'] ?? '')),
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}
/** @param array<string,mixed> $data */
public function create(array $data, int $userId): CourierSurchargeQuote
{
@ -275,6 +312,18 @@ class CourierSurchargeQuoteRepository
];
}
/** @param array<int,mixed> $types @return list<string> */
private function normalizedTypes(array $types): array
{
$types = array_values(array_unique(array_filter(array_map(
static fn (mixed $type): string => trim((string) $type),
$types,
))));
sort($types, SORT_STRING);
return $types;
}
/** @return array<string,mixed> */
private function present(CourierSurchargeQuote $quote): array
{

View File

@ -0,0 +1,382 @@
<?php
namespace App\Business;
use App\Models\Business\CourierSurchargeQuote;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class CourierSurchargeQuoteWorkbookImporter
{
public const MAX_ROWS = 500;
public const DUPLICATE_SKIP = 'skip';
public const DUPLICATE_UPDATE = 'update';
public const HEADERS = [
'快递名称',
'快递归属',
'对账人',
'附加费类型',
'结算方式',
'计价标准',
'生效条件',
'生效日期',
'状态',
'备注',
];
public function __construct(private readonly CourierSurchargeQuoteRepository $quotes)
{
}
public function template(): StreamedResponse
{
$types = $this->quotes->activeTypeNames();
return response()->streamDownload(function () use ($types): void {
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('附加费报价');
$sheet->fromArray(self::HEADERS, null, 'A1');
$lastColumn = Coordinate::stringFromColumnIndex(count(self::HEADERS));
$headerRange = 'A1:' . $lastColumn . '1';
$sheet->getStyle($headerRange)->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'D8D2C7']]],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
$sheet->freezePane('A2');
$sheet->setAutoFilter($headerRange);
$widths = [18, 14, 14, 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<array{line:int,data:array<string,mixed>,display:array<string,string>,errors:list<string>,result:string,result_label:string,existing_id:?int}>,
* summary:array{total:int,valid:int,new:int,duplicate:int,invalid:int}
* }
*/
public function preview(UploadedFile $file): array
{
$extension = mb_strtolower($file->getClientOriginalExtension());
if (! in_array($extension, ['xlsx', 'xls', 'csv'], true)) {
throw ValidationException::withMessages(['import' => '仅支持 xlsx、xls 或 csv 文件。']);
}
try {
$reader = IOFactory::createReader(match ($extension) {
'xlsx' => 'Xlsx',
'xls' => 'Xls',
'csv' => 'Csv',
});
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($file->getRealPath());
$rows = $spreadsheet->getActiveSheet()->toArray(null, false, false, false);
} catch (Throwable) {
throw ValidationException::withMessages([
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
]);
} finally {
if (isset($spreadsheet)) {
$spreadsheet->disconnectWorksheets();
}
}
$headers = array_map(fn (mixed $value): string => $this->text($value), $rows[0] ?? []);
while ($headers !== [] && end($headers) === '') {
array_pop($headers);
}
if ($headers !== self::HEADERS) {
throw ValidationException::withMessages([
'import' => '表头不正确。请下载最新模板,并保持 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<array{line:int,data:array<string,mixed>,errors:list<string>}> $rows
* @return array{created:int,updated:int,skipped:int,total:int}
*/
public function commit(array $rows, string $duplicatePolicy, int $userId): array
{
if (! in_array($duplicatePolicy, [self::DUPLICATE_SKIP, self::DUPLICATE_UPDATE], true)) {
throw ValidationException::withMessages(['duplicate_policy' => '请选择已有规则的处理方式。']);
}
if ($rows === [] || count($rows) > self::MAX_ROWS || collect($rows)->contains(fn (array $row): bool => ($row['errors'] ?? []) !== [])) {
throw ValidationException::withMessages(['import' => '预览数据无效或包含错误,请重新上传文件。']);
}
$activeTypes = $this->quotes->activeTypeNames();
foreach ($rows as $row) {
$inactive = array_values(array_diff((array) ($row['data']['types'] ?? []), $activeTypes));
if ($inactive !== []) {
throw ValidationException::withMessages([
'import' => '附加费类型“' . implode('、', $inactive) . '”已停用或不存在,请重新预览。',
]);
}
}
$counts = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'total' => count($rows)];
DB::transaction(function () use ($rows, $duplicatePolicy, $userId, &$counts): void {
foreach ($rows as $row) {
$data = $row['data'];
$status = $data['status'];
unset($data['status']);
$existing = $this->quotes->findImportDuplicate($data, true);
if ($existing && $duplicatePolicy === self::DUPLICATE_SKIP) {
$counts['skipped']++;
continue;
}
if ($existing) {
$quote = $this->quotes->update($existing, $data, $userId);
$counts['updated']++;
} else {
$quote = $this->quotes->create($data, $userId);
$counts['created']++;
}
if ($quote->status !== $status) {
$this->quotes->setStatus($quote, $status, $userId);
}
}
});
return $counts;
}
/** @return array{line:int,data:array<string,mixed>,display:array<string,string>,errors:list<string>,result:string,result_label:string,existing_id:?int} */
private function normalizeRow(array $values, int $line): array
{
$raw = array_combine(self::HEADERS, $values);
$display = [];
foreach ($raw as $header => $value) {
$display[$header] = $this->text($value);
}
$errors = [];
foreach (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<string,string> $values @param list<string> $errors */
private function checkLength(array $values, string $field, int $max, array &$errors): void
{
if (mb_strlen($values[$field]) > $max) {
$errors[] = $field . '不能超过 ' . $max . ' 个字符。';
}
}
private function date(mixed $value): ?string
{
if (is_numeric($value) && (float) $value > 0) {
try {
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d');
} catch (Throwable) {
return null;
}
}
$value = $this->text($value);
if (! preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches)) {
return null;
}
return checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1]) ? $value : null;
}
private function text(mixed $value): string
{
return trim(str_replace("\xEF\xBB\xBF", '', (string) ($value ?? '')));
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\DewuSettlementBillImporter;
use RuntimeException;
use Throwable;
class ImportDewuSettlementBills extends Command
{
protected $signature = 'dewu-bills:import-authoritative
{path : 聚胜算导出的得物完整账单 XLSX}
{--from= : 账期开始日期,格式 YYYY-MM-DD}
{--to= : 账期结束日期,格式 YYYY-MM-DD}
{--expected-count= : 完整账单权威笔数}
{--expected-income= : 完整账单权威收入}
{--expected-expense= : 完整账单权威支出}
{--batch=1000 : 分批写入行数}';
protected $description = '导入并核验聚胜算得物完整账单(保留全部渠道)';
public function handle(DewuSettlementBillImporter $importer): int
{
foreach (['from', 'to', 'expected-count', 'expected-income', 'expected-expense'] as $option) {
if (trim((string) $this->option($option)) === '') {
$this->error("缺少 --{$option},完整账单导入必须提供账期和三项权威控制数。");
return self::FAILURE;
}
}
try {
$result = $importer->import(
(string) $this->argument('path'),
(string) $this->option('from'),
(string) $this->option('to'),
(int) $this->option('expected-count'),
(string) $this->option('expected-income'),
(string) $this->option('expected-expense'),
(int) $this->option('batch'),
);
} catch (RuntimeException $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
} catch (Throwable $exception) {
report($exception);
$this->error('得物完整账单导入失败,详细原因已记录到受保护日志。');
return self::FAILURE;
}
$this->table(['状态', '账单行数', '跳过行数', '店铺数', '账单项目数', '收入', '支出', '净额', '账期'], [[
$result['status'],
$result['row_count'],
$result['excluded_rows'],
$result['store_count'],
$result['bill_item_count'],
$result['income_total'],
$result['expense_total'],
$result['net_total'],
$result['period_start'] . ' 至 ' . $result['period_end'],
]]);
$this->line('渠道:' . collect($result['channel_counts'])
->map(fn (int $count, string $channel): string => "{$channel} {$count}")
->implode(';'));
$this->line('SHA-256: ' . $result['source_sha256']);
return self::SUCCESS;
}
}

View File

@ -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;
}
}

View File

@ -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) {

View File

@ -0,0 +1,72 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\PinduoduoSettlementBillImporter;
use RuntimeException;
use Throwable;
class ImportPinduoduoSettlementBills extends Command
{
protected $signature = 'pinduoduo-bills:import-authoritative
{path : 聚胜算导出的拼多多完整账单 XLSX}
{--from= : 账期开始日期,格式 YYYY-MM-DD}
{--to= : 账期结束日期,格式 YYYY-MM-DD}
{--expected-count= : 完整账单权威笔数}
{--expected-income= : 完整账单权威收入}
{--expected-expense= : 完整账单权威支出}
{--batch=1000 : 分批写入行数}';
protected $description = '导入并核验聚胜算拼多多完整账单(保留全部账户类型)';
public function handle(PinduoduoSettlementBillImporter $importer): int
{
foreach (['from', 'to', 'expected-count', 'expected-income', 'expected-expense'] as $option) {
if (trim((string) $this->option($option)) === '') {
$this->error("缺少 --{$option},完整账单导入必须提供账期和三项权威控制数。");
return self::FAILURE;
}
}
try {
$result = $importer->import(
(string) $this->argument('path'),
(string) $this->option('from'),
(string) $this->option('to'),
(int) $this->option('expected-count'),
(string) $this->option('expected-income'),
(string) $this->option('expected-expense'),
(int) $this->option('batch'),
);
} catch (RuntimeException $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
} catch (Throwable $exception) {
report($exception);
$this->error('拼多多完整货款账单导入失败,详细原因已记录到受保护日志。');
return self::FAILURE;
}
$this->table(['状态', '账单行数', '跳过行数', '店铺数', '账单项目数', '收入', '支出', '净额', '账期'], [[
$result['status'],
$result['row_count'],
$result['excluded_account_rows'],
$result['store_count'],
$result['bill_item_count'],
$result['income_total'],
$result['expense_total'],
$result['net_total'],
$result['period_start'] . ' 至 ' . $result['period_end'],
]]);
$this->line('账户类型:' . collect($result['account_type_counts'])
->map(fn (int $count, string $account): string => "{$account} {$count}")
->implode(';'));
$this->line('SHA-256: ' . $result['source_sha256']);
return self::SUCCESS;
}
}

View File

@ -16,7 +16,7 @@ class SyncProductPurchasePriceMirror extends Command
{--batch= : 每批最多读取行数}
{--max-batches= : 本轮最多批次数,0 表示直到完成}';
protected $description = '同步采购单管理的普通商品单价,并保留可断点的共享业务镜像';
protected $description = '同步采购单管理明细和普通商品单价,并保留可断点的共享业务镜像';
public function handle(
ProductPurchasePriceMirrorRepository $mirror,

View File

@ -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();
}
}
/**

View File

@ -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'])),

View File

@ -3,15 +3,20 @@
namespace App\Http\Controllers\Business;
use App\Business\CourierSurchargeQuoteRepository;
use App\Business\CourierSurchargeQuoteWorkbookImporter;
use App\Models\Business\CourierSurchargeQuote;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierSurchargeQuotes extends Controller
{
public function __construct(private readonly CourierSurchargeQuoteRepository $quotes)
{
public function __construct(
private readonly CourierSurchargeQuoteRepository $quotes,
private readonly CourierSurchargeQuoteWorkbookImporter $workbookImporter,
) {
$this->middleware('auth');
$this->middleware('company.identify');
$this->middleware('business.page:courier-payables,courier-surcharge');
@ -49,6 +54,79 @@ class CourierSurchargeQuotes extends Controller
return $this->redirect()->with('success', '报价规则状态已更新。');
}
public function importTemplate(): StreamedResponse
{
abort_unless($this->quotes->ready(), 503, '附加费报价数据表尚未完成迁移,请联系管理员。');
return $this->workbookImporter->template();
}
public function importPreview(Request $request): RedirectResponse
{
abort_unless($this->quotes->ready(), 503, '附加费报价数据表尚未完成迁移,请联系管理员。');
$file = $request->validate([
'import_file' => ['required', 'file', 'max:10240'],
])['import_file'];
$preview = $this->workbookImporter->preview($file);
$token = Str::random(48);
$previews = collect((array) $request->session()->get('courier_surcharge_import_previews', []))
->filter(fn (mixed $item): bool => is_array($item) && (int) ($item['expires_at'] ?? 0) >= now()->timestamp)
->take(-2)
->all();
$previews[$token] = [
'company_id' => (int) company_id(),
'user_id' => (int) user_id(),
'expires_at' => now()->addMinutes(20)->timestamp,
'rows' => $preview['rows'],
];
$request->session()->put('courier_surcharge_import_previews', $previews);
return $this->redirect()->with('courierSurchargeImportPreview', $preview + ['token' => $token]);
}
public function importCommit(Request $request): RedirectResponse
{
abort_unless($this->quotes->ready(), 503, '附加费报价数据表尚未完成迁移,请联系管理员。');
$validated = $request->validate([
'preview_token' => ['required', 'string', 'size:48'],
'duplicate_policy' => ['required', 'in:' . CourierSurchargeQuoteWorkbookImporter::DUPLICATE_SKIP . ',' . CourierSurchargeQuoteWorkbookImporter::DUPLICATE_UPDATE],
]);
$previews = (array) $request->session()->get('courier_surcharge_import_previews', []);
$payload = $previews[$validated['preview_token']] ?? null;
if (! is_array($payload)
|| (int) ($payload['company_id'] ?? 0) !== (int) company_id()
|| (int) ($payload['user_id'] ?? 0) !== (int) user_id()
|| (int) ($payload['expires_at'] ?? 0) < now()->timestamp) {
unset($previews[$validated['preview_token']]);
$request->session()->put('courier_surcharge_import_previews', $previews);
return $this->redirect()->withErrors([
'import' => '导入预览已失效或不属于当前账号,请重新选择文件预览。',
]);
}
$counts = $this->workbookImporter->commit(
(array) ($payload['rows'] ?? []),
$validated['duplicate_policy'],
(int) user_id(),
);
unset($previews[$validated['preview_token']]);
$request->session()->put('courier_surcharge_import_previews', $previews);
return $this->redirect()->with(
'success',
sprintf(
'附加费报价导入完成:新增 %d 条,更新 %d 条,跳过 %d 条。',
$counts['created'],
$counts['updated'],
$counts['skipped'],
),
);
}
/** @return array<string,mixed> */
private function validated(Request $request): array
{

View File

@ -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<string,string> */
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<array{field:string,label:string,type:string}> */
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' => '供应商应付数据暂时无法读取,请稍后重试。',
];

View File

@ -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(

View File

@ -0,0 +1,196 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
use Modules\EcommerceFinance\Services\SupplierPayableMaintenanceRegistry;
use Modules\EcommerceFinance\Services\SupplierPayableImportTemplate;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Modules\EcommerceFinance\Services\SupplierPayableWorkbookImporter;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SupplierPayableActions extends Controller
{
public function __construct(
private readonly SupplierPayableRepository $supplier,
private readonly GoodsReconciliationRepository $goods,
private readonly SupplierPayableReportRepository $reports,
private readonly SupplierPayableMaintenanceRegistry $registry,
private readonly SupplierPayableImportTemplate $template,
private readonly SupplierPayableOverrideStore $overrides,
private readonly SupplierPayableWorkbookImporter $importer,
) {
$this->middleware('auth');
$this->middleware('company.identify');
}
public function definition(Request $request): JsonResponse
{
return response()->json($this->registry->publicDefinition($this->dataset($request)));
}
public function update(Request $request, string $recordKey): JsonResponse|RedirectResponse
{
$dataset = $this->dataset($request);
$values = $this->registry->normalize($dataset, (array) $request->input('values', []));
$result = $this->overrides->save(
$dataset,
$recordKey,
$values,
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
);
return $request->expectsJson()
? response()->json(['message' => '保存成功。'] + $result)
: back()->with('success', '保存成功。');
}
public function import(Request $request): JsonResponse|RedirectResponse
{
$request->validate(['import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240']]);
$result = $this->importer->import(
$this->dataset($request),
$request->file('import'),
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
);
$message = "导入完成:新增维护 {$result['created']} 条,更新维护 {$result['updated']} 条。";
return $request->expectsJson()
? response()->json(['message' => $message] + $result)
: back()->with('success', $message);
}
public function template(Request $request): StreamedResponse
{
$dataset = $this->dataset($request);
return $this->template->download($this->title($dataset), $this->registry->fields($dataset));
}
public function export(Request $request): StreamedResponse
{
$dataset = $this->dataset($request);
$fields = $this->registry->fields($dataset);
$filters = $this->filters($dataset, $request);
$companyId = (int) (company_id() ?: 1);
return response()->streamDownload(function () use ($dataset, $fields, $filters, $companyId): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new RuntimeException('无法创建导出文件。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_merge(['记录标识'], array_column($fields, 'label')));
foreach ($this->allRows($dataset, $filters) as $batch) {
foreach ($this->overrides->decorate($dataset, $batch, $companyId) as $row) {
$values = [$this->csvText($row['_action_key'] ?? '')];
foreach ($fields as $field => $definition) {
$values[] = $definition['type'] === 'number' && is_numeric($row[$field] ?? null)
? $row[$field]
: $this->csvText($row[$field] ?? '');
}
fputcsv($csv, $values);
}
}
fclose($csv);
}, $this->title($dataset) . '_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return iterable<array<int,array<string,mixed>>> */
private function allRows(string $dataset, array $filters): iterable
{
if ($dataset === 'payment-reconciliation' && ($filters['record_type'] ?? 'all') === 'all') {
foreach (['purchase', 'sale'] as $type) {
yield from $this->allRows($dataset, array_replace($filters, ['record_type' => $type]));
}
return;
}
$page = 1;
do {
$result = $this->page($dataset, $filters, $page, 100);
$rows = array_slice((array) ($result['data'] ?? $result['rows'] ?? []), 0, 100);
if ($rows !== []) {
yield $rows;
}
$total = (int) ($result['total'] ?? -1);
$more = $rows !== [] && ($total < 0 ? count($rows) === 100 : $page * 100 < $total);
$page++;
} while ($more);
}
private function page(string $dataset, array $filters, int $page, int $limit): array
{
return match ($dataset) {
'payment-reconciliation' => $this->goods->list($filters, $page, $limit),
'return-reconciliation' => $this->supplier->returnReconciliations($filters, $page, $limit),
'invoice-deductions' => $this->supplier->invoiceDeductions($filters, $page, $limit),
'compensation-details' => $this->supplier->compensationDetails($filters, $page, $limit),
'advance-details' => $this->supplier->advanceDetails($filters, $page, $limit),
'sample-deductions' => $this->supplier->sampleDeductions($filters, $page, $limit),
'delivery-unpriced' => $this->supplier->deliveryUnpriced($filters, $page, $limit),
'return-unpriced' => $this->supplier->returnUnpriced($filters, $page, $limit),
'other-adjustments' => $this->supplier->otherAdjustments($filters, $page, $limit),
'dropshipping-express' => $this->supplier->dropshippingExpress($filters, $page, $limit),
'board-deductions' => $this->reports->boardDeductions($filters, $page, $limit),
'supplier-payables-report' => $this->reports->supplierPayables($filters, $page, $limit),
};
}
private function filters(string $dataset, Request $request): array
{
$allowed = match ($dataset) {
'payment-reconciliation' => ['record_type', 'supplier', 'warehouse', 'stock_no', 'purchase_no', 'product_code', 'express_no', 'send_time_from', 'send_time_to', 'create_time_from', 'create_time_to', 'sent_status', 'has_diff'],
'return-reconciliation', 'return-unpriced' => ['supplier', 'warehouse', 'sku_id', 'express_no', 'date_from', 'date_to'],
'invoice-deductions', 'compensation-details', 'advance-details', 'other-adjustments' => ['supplier', 'month_from', 'month_to'],
'sample-deductions' => ['supplier', 'department', 'creator', 'sku_id', 'date_from', 'date_to'],
'delivery-unpriced' => ['supplier', 'warehouse', 'purchase', 'barcode', 'express_no', 'date_from', 'date_to'],
'dropshipping-express' => ['supplier', 'express_name', 'month_from', 'month_to'],
'board-deductions' => ['stock_in_date', 'stock_out_date', 'furniture_factory', 'lamination_factory', 'deduction_type', 'material_name', 'claim_number'],
'supplier-payables-report' => ['month', 'supplier', 'checker', 'settlement_method'],
};
return collect($request->only($allowed))
->map(static fn (mixed $value): mixed => is_string($value) ? trim($value) : $value)
->reject(static fn (mixed $value): bool => $value === '' || $value === null || $value === false)
->all();
}
private function dataset(Request $request): string
{
$dataset = (string) $request->route('dataset');
$this->registry->fields($dataset);
return $dataset;
}
private function title(string $dataset): string
{
return [
'payment-reconciliation' => '货款对账', 'return-reconciliation' => '退货对账',
'invoice-deductions' => '发票扣款', 'compensation-details' => '赔偿明细',
'advance-details' => '借支明细', 'sample-deductions' => '样品扣款',
'delivery-unpriced' => '送货未定价', 'return-unpriced' => '退货未定价',
'other-adjustments' => '其他扣款补款', 'dropshipping-express' => '代发快递',
'board-deductions' => '板材扣款', 'supplier-payables-report' => '供应商应付表',
][$dataset];
}
private function csvText(mixed $value): string
{
$value = (string) $value;
return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value;
}
}

View File

@ -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) ?: '协议签署';

View File

@ -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

View File

@ -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' => '赔偿明细'],

View File

@ -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'),

46
config/jushuitan.php Normal file
View File

@ -0,0 +1,46 @@
<?php
/**
* 聚水潭(聚石塔/奇门)开放平台接入配置。
*
* caiwu 自建奇门客户端直连聚水潭拉取原始销售出库单,脱离 jiansun。
* 复用工作平台现有授权(同一套 app_key / app_secret / customerId / v)。
*
* 凭证只放 runtime/.env 或系统数据库私有配置,权限 600,不入 Git / PR / 日志。
* 未填 app_key / app_secret 前,客户端拒绝发起请求,不会同步任何数据。
*/
return [
// 奇门(聚石塔)网关地址,对应工作平台配置 ErpJSTUrl。
'gateway_url' => 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)
],
];

View File

@ -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' => [

View File

@ -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',
);
});
}
}

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$schema = Schema::connection(config('database.supplier_payable_maintenance_connection'));
if (! $schema->hasTable('supplier_payable_overrides')) {
$schema->create('supplier_payable_overrides', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->unsignedInteger('company_id');
$table->string('dataset', 64);
$table->string('record_key', 191);
$table->json('values');
$table->unsignedInteger('created_by')->nullable();
$table->unsignedInteger('updated_by')->nullable();
$table->timestamps();
$table->unique(['company_id', 'dataset', 'record_key'], 'supplier_payable_override_unique');
$table->index(['company_id', 'dataset'], 'supplier_payable_override_dataset');
});
}
if (! $schema->hasTable('supplier_payable_override_events')) {
$schema->create('supplier_payable_override_events', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->unsignedInteger('company_id');
$table->string('dataset', 64);
$table->string('record_key', 191);
$table->string('source', 24);
$table->json('before_values')->nullable();
$table->json('after_values');
$table->unsignedInteger('user_id')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(['company_id', 'dataset', 'record_key'], 'supplier_payable_override_event_lookup');
});
}
}
public function down(): void
{
$schema = Schema::connection(config('database.supplier_payable_maintenance_connection'));
$schema->dropIfExists('supplier_payable_override_events');
$schema->dropIfExists('supplier_payable_overrides');
}
};

View File

@ -0,0 +1,110 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('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');
}
};

View File

@ -0,0 +1,119 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('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');
}
};

View File

@ -0,0 +1,94 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->connection());
if ($schema->hasTable('product_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');
});
}
}
};

View File

@ -0,0 +1,68 @@
<?php
namespace Database\Seeds;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* 发货明细演示数据(仅本地看界面用)。写入 jst_oms_shipping(47 列)。
* php artisan db:seed --class="Database\Seeds\JushuitanShippingDemoSeeder"
* 联调真实接口后清空即可(source_id 以 9000000 起)。
*/
class JushuitanShippingDemoSeeder extends Seeder
{
public function run(): void
{
$db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
$now = now();
$samples = [
// 快递单号, 交易单号, 商品ID, 条码, 商品名称, 数量, 省, 市, 区, 收货人, 手机, 店铺, 快递, 仓库, 供应商, 平台, 货号, 运费, 线上重, 实称重, 长,宽,高, 结算运费
['JDV028245430835','3545486005885966','10035038189982','GNSF000504','折叠床 单人 1.2m 奶白色',2,'北京','北京','朝阳区','张三','138****0001','京喜自营工厂店','京喜京东快运','浙江华韵代发仓','华韵','京东商城','ZDC-001',12.00,18.5,19.0,80,78,68,15.20],
['YT7788990011223','3310398519776020','10062860613','GNSF000208','藤椅躺椅 加大款 棕色',1,'浙江省','杭州市','余杭区','李四','139****0002','优选家居旗舰店','圆通速递','南昌樵舍仓','简尚集采','天猫','TYY-001',6.50,8.0,8.2,60,55,40,7.80],
['SF1234567890123','3310398951698084','10061046499','GNSF000112','康米特椅 户外折叠 军绿',1,'广东省','深圳市','南山区','王五','137****0003','京选家具专营','顺丰速运','常州总仓','固图家居','京东','TYK-002',9.20,3.2,3.4,55,42,12,9.20],
['ZTO5566778899001','3310851253260175','10061042791','GNSF000377','儿童学习椅 可升降 粉',1,'四川省','成都市','武侯区','赵六','135****0004','儿童学习专营店','中通快递','南昌樵舍仓','绒灿家居','拼多多','EXX-006',6.40,6.4,6.6,52,40,45,6.40],
['YD9900112233445','3311032908970014','10061082943','GNSF000501','折叠床 双人 1.5m 胡桃',1,'山东省','青岛市','市南区','孙七','136****0005','家具工厂直营店','韵达快递','常州总仓','华韵','淘宝','ZDC-002',15.80,15.8,16.1,90,70,20,16.50],
['JD0088123456789','3545486009911223','10035038777','GNSF000733','鳄鱼椅 网布透气 黑',2,'江苏省','常州市','武进区','周八','133****0006','亿家木旗舰店','京东快递','浙江华韵代发仓','简尚集采','京东商城','EYY-009',10.00,9.0,9.3,58,46,48,10.00],
];
$i = 0;
foreach ($samples as $r) {
[$expno,$tradeno,$itemid,$barcode,$pname,$qty,$prov,$city,$dist,$rname,$mobile,$shop,$express,$wh,$supplier,$platform,$procode,$freight,$onlineW,$actualW,$L,$W,$H,$settleFreight] = $r;
$shipTime = $now->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 起)。');
}
}

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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 <CSV路径> <YYYY-MM> --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` 的工作平台价格记录混为同一来源。

View File

@ -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 与控制数,再调用受控导入命令。源文件未生成、下载失败、会话失效、控制数不一致、生产连接不可用或迁移未完成时停止,不使用旧镜像补数。
上线前需单独完成生产备份、迁移授权、发布以及首次导入授权。回滚时使用已归档的上一版源文件及其控制数重新导入相同账期。

View File

@ -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'],

View File

@ -0,0 +1,37 @@
<?php
namespace Modules\EcommerceFinance\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Modules\EcommerceFinance\Services\Jushuitan\ShippingDataSynchronizer;
/**
* 聚水潭发货数据同步命令。
*
* php artisan jushuitan:sync-shipping # 增量(按水位 - 重叠窗口)
* php artisan jushuitan:sync-shipping --from=... --to=... # 指定时间窗(首次全量分批用)
*/
class SyncJushuitanShipping extends Command
{
protected $signature = 'jushuitan:sync-shipping {--from=} {--to=}';
protected $description = '从聚水潭(奇门)拉取销售出库单发货数据到 caiwu 共享镜像库';
public function handle(): int
{
$from = $this->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;
}
}

View File

@ -0,0 +1,129 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 聚水潭销售出库单原始表(caiwu 直连奇门拉取,脱离 jiansun)。
*
* 对应聚水潭 erp.sradesku.query 返回的四个层级:
* 主单 erp_sctrade / 平台商品明细 items / SKU 明细 skus / 收货人 receiver。
* 表落在共享业务镜像库(caiwu_business_mirror)。
*
* 说明:这里只显式建“发货明细加工需要 + 常用筛选”的字段,完整报文存 raw_json
* 保真;后续要用到更多字段时按需补列即可(不必回拉上游)。
* 稳定键 source_id(聚水潭单据 id);增量列 modified。
*/
return new class extends Migration
{
private function connection(): ?string
{
return config('jushuitan.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->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');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 聚水潭同步状态表:记录每个数据集的增量水位与最近同步状态,
* 供页面显示“同步中/最近完成”,与共享镜像状态语义一致。
*/
return new class extends Migration
{
private function connection(): ?string
{
return config('jushuitan.connection', 'business_mirror');
}
public function up(): void
{
if (Schema::connection($this->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');
}
};

View File

@ -0,0 +1,89 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 发货数据扁平展示表(对齐工作平台 fys_omsDataDay 的 47 列,按界面顺序)。
* 页面直接读本表;真实同步时由 erp_sctrade 原始表加工写入。
* 落 jushuitan_local(本地容器),字段全部可空。
*/
return new class extends Migration
{
private function conn(): ?string { return config('jushuitan.connection', 'jushuitan_local'); }
public function up(): void
{
$schema = Schema::connection($this->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');
}
};

View File

@ -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' => '得物',

View File

@ -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'],

View File

@ -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'],

View File

@ -0,0 +1,178 @@
<?php
namespace Modules\EcommerceFinance\Http\Controllers;
use App\Utilities\SidebarPermissions;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\AccountBillColumns;
use Modules\EcommerceFinance\Repositories\DewuSettlementBillRepository;
use RuntimeException;
class DewuAccountBills extends Controller
{
public function index(Request $request, DewuSettlementBillRepository $repository)
{
$this->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<string,array<int,string>> */
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<string,mixed> $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, '当前账号没有店铺收款账户账单权限。');
}
}

View File

@ -0,0 +1,178 @@
<?php
namespace Modules\EcommerceFinance\Http\Controllers;
use App\Utilities\SidebarPermissions;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\AccountBillColumns;
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
use RuntimeException;
class PinduoduoAccountBills extends Controller
{
public function index(Request $request, PinduoduoSettlementBillRepository $repository)
{
$this->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<string,array<int,string>> */
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<string,mixed> $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, '当前账号没有店铺收款账户账单权限。');
}
}

View File

@ -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

View File

@ -0,0 +1,322 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use DateTimeImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
class DewuSettlementBillRepository
{
private const IMPORT_TABLE = 'dewu_settlement_bill_imports';
private const ROW_TABLE = 'dewu_settlement_bill_rows';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->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<string>,bill_items:list<string>} */
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<array<string,mixed>>,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<string,mixed> */
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<int,array<string,mixed>> */
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<string,mixed> $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<string,mixed> */
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;
}
}

View File

@ -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<string,array<string,mixed>>|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<array<string,mixed>>,total:int,status:string,last_synced_at:?string,summary:array<string,mixed>} */
/** @return array{data:list<array<string,mixed>>,total:int,status:string,last_synced_at:?string,summary:array<string,mixed>,source_statuses:array<string,array<string,mixed>>} */
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<string,array<string,mixed>> */
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<string,mixed> */
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;
}
}

View File

@ -0,0 +1,304 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use DateTimeImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
class PinduoduoSettlementBillRepository
{
private const IMPORT_TABLE = 'pdd_settlement_bill_imports';
private const ROW_TABLE = 'pdd_settlement_bill_rows';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->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<string>,bill_items:list<string>} */
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<array<string,mixed>>,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<string,mixed> */
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<int,array<string,mixed>> */
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<string,mixed> $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<string,mixed> */
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;
}
}

View File

@ -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<string,mixed>|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;
}
}

View File

@ -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 '

View File

@ -0,0 +1,134 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
/**
* 发货明细只读仓库。读取扁平表 jst_oms_shipping(对齐工作平台发货数据 47 列),
* 供“发货数据”页面展示与后续快递运费核算使用。
*/
class ShippingOrderRepository
{
private readonly ConnectionInterface $db;
public function __construct()
{
$this->db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
}
/**
* @param array<string,string> $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<string,string> $filters
* @return \Generator<int,array<string,mixed>>
*/
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<string,string> $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];
}
}
}

View File

@ -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,

View File

@ -0,0 +1,731 @@
<?php
namespace Modules\EcommerceFinance\Services;
use DateTimeImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use RuntimeException;
use SimpleXMLElement;
use Throwable;
use XMLReader;
use ZipArchive;
class DewuSettlementBillImporter
{
private const IMPORT_TABLE = 'dewu_settlement_bill_imports';
private const STAGING_TABLE = 'dewu_settlement_bill_staging';
private const ROW_TABLE = 'dewu_settlement_bill_rows';
private const HEADER_ALIASES = [
'record_code' => ['记录编码'],
'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<string,int>,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<string,int>,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<int,string> */
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<int,string> $cells @return array<string,int> */
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<string> */
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<string,int> */
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;
}
}

View File

@ -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();

View File

@ -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();

View File

@ -0,0 +1,130 @@
<?php
namespace Modules\EcommerceFinance\Services\Jushuitan;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
/**
* 聚水潭(聚石塔 / 阿里巴巴奇门)开放平台客户端。
*
* 复刻工作平台 JSCrm 的鉴权与调用方式:
* - 基础参数:app_key, customerId, format, method, simplify, timestamp, v, sign
* - 签名(奇门 MD5):MD5( secret + 依次拼接每个基础参数的 键名+键值(跳过 sign) + body + secret ),转大写十六进制
* - 发送:基础参数放 URL query,业务参数 body 作为 JSON 请求体
*
* 只读取数用途,带超时与重试。凭证来自 config('jushuitan'),缺失即拒绝请求。
*/
class QimenClient
{
/** 参与签名的基础参数顺序,必须与工作平台 BaseRequestModel 声明顺序一致。 */
private const SIGN_ORDER = ['app_key', 'customerId', 'format', 'method', 'simplify', 'timestamp', 'v'];
public function __construct(private readonly array $config)
{
}
public static function fromConfig(): self
{
return new self((array) config('jushuitan'));
}
/**
* 调用一个奇门接口,返回解析后的数组。
*
* @param string $method 接口方法名,如 erp.sctrade.query
* @param array $body 业务参数
* @return array 聚水潭返回的 JSON 解析结果
*/
public function call(string $method, array $body): array
{
$this->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<string,string> */
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},已拒绝请求(不会同步任何数据)。");
}
}
}
}

View File

@ -0,0 +1,278 @@
<?php
namespace Modules\EcommerceFinance\Services\Jushuitan;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* 聚水潭发货(销售出库单)同步器。
*
* 复刻工作平台 ErpTradeData 的两步拉取:
* 1) erp.sctrade.query 按修改时间窗口分页拉出库单列表 -> 得到每单 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<int,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] ?? '';
}
}

View File

@ -0,0 +1,715 @@
<?php
namespace Modules\EcommerceFinance\Services;
use DateTimeImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use RuntimeException;
use SimpleXMLElement;
use Throwable;
use XMLReader;
use ZipArchive;
class PinduoduoSettlementBillImporter
{
public const ACCOUNT_NAME = '拼多多货款账户';
private const IMPORT_TABLE = 'pdd_settlement_bill_imports';
private const STAGING_TABLE = 'pdd_settlement_bill_staging';
private const ROW_TABLE = 'pdd_settlement_bill_rows';
private const HEADER_ALIASES = [
'record_code' => ['记录编码'],
'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<string,int>,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<string,int>,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<int,string> */
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<int,string> $cells @return array<string,int> */
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<string> */
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<string,int> */
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;
}
}

View File

@ -39,6 +39,25 @@ class ProductPriceWorkbookImporter
{
}
/** @return array<string,array{label:string,type:string}> */
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
{

View File

@ -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)

View File

@ -0,0 +1,89 @@
<?php
namespace Modules\EcommerceFinance\Services;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SupplierPayableImportTemplate
{
/** @param array<string,array{label:string,type:string}> $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);
}
}

View File

@ -0,0 +1,167 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Validation\ValidationException;
class SupplierPayableMaintenanceRegistry
{
/** @return array<string,array{label:string,type:string}> */
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(),
];
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Closure;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\DB;
class SupplierPayableOverrideStore
{
public function key(string $dataset, array $row): string
{
if (isset($row['sourceId']) && $row['sourceId'] !== '') {
return 'id:' . (string) ($row['recordType'] ?? '') . ':' . (string) $row['sourceId'];
}
if (isset($row['id']) && $row['id'] !== '') {
return 'id:' . (string) $row['id'];
}
$immutable = $row;
unset($immutable['_action_key'], $immutable['_overridden']);
return 'hash:' . hash('sha256', json_encode($immutable, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
/** @param array<int,array<string,mixed>> $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'));
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Throwable;
class SupplierPayableWorkbookImporter
{
public function __construct(
private readonly SupplierPayableMaintenanceRegistry $registry,
private readonly SupplierPayableOverrideStore $overrides,
) {
}
/** @return array{created:int,updated:int,rows:int} */
public function import(string $dataset, UploadedFile $file, int $companyId, ?int $userId): array
{
$definitions = $this->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)));
}
}

View File

@ -16,19 +16,19 @@
</el-select>
<input v-else v-model.trim="filters.bill_item" placeholder="账单项目" />
</label>
<div class="tw-filter-field tw-date">
<div class="tw-filter-field tw-date" :class="{ 'is-stable-date': usesStableDateControl }">
<span class="tw-filter-label">{{ ['jd-transactions','jd-wallet'].includes(billType) ? '费用结算时间' : '发生时间' }}</span>
<div class="tw-date-row">
<span class="tw-date-control has-picker">
<span class="tw-date-control has-picker" @click="usesStableDateControl && openDatePicker('startDate')">
<flat-picker ref="startDate" :value="filters.start" :config="startDateConfig" aria-label="开始日期" placeholder="开始日期" @on-change="(_, value) => changeDate('start', value)" />
<button v-if="filters.start" type="button" class="tw-date-clear" title="清除开始日期" aria-label="清除开始日期" @click="changeDate('start', '')"><span class="material-icons-outlined" aria-hidden="true">close</span></button>
<button type="button" class="tw-date-picker" title="选择开始日期" aria-label="选择开始日期" @click="openDatePicker('startDate')"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button>
<button v-if="filters.start" type="button" class="tw-date-clear" title="清除开始日期" aria-label="清除开始日期" @click.stop="changeDate('start', '')"><span class="material-icons-outlined" aria-hidden="true">close</span></button>
<button type="button" class="tw-date-picker" title="选择开始日期" aria-label="选择开始日期" @click.stop="openDatePicker('startDate')"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button>
</span>
<b>至</b>
<span class="tw-date-control has-picker">
<span class="tw-date-control has-picker" @click="usesStableDateControl && openDatePicker('endDate')">
<flat-picker ref="endDate" :value="filters.end" :config="endDateConfig" aria-label="结束日期" placeholder="结束日期" @on-change="(_, value) => changeDate('end', value)" />
<button v-if="filters.end" type="button" class="tw-date-clear" title="清除结束日期" aria-label="清除结束日期" @click="changeDate('end', '')"><span class="material-icons-outlined" aria-hidden="true">close</span></button>
<button type="button" class="tw-date-picker" title="选择结束日期" aria-label="选择结束日期" @click="openDatePicker('endDate')"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button>
<button v-if="filters.end" type="button" class="tw-date-clear" title="清除结束日期" aria-label="清除结束日期" @click.stop="changeDate('end', '')"><span class="material-icons-outlined" aria-hidden="true">close</span></button>
<button type="button" class="tw-date-picker" title="选择结束日期" aria-label="选择结束日期" @click.stop="openDatePicker('endDate')"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button>
</span>
</div>
</div>
@ -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}

View File

@ -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);

View File

@ -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: [],

View File

@ -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: [],

View File

@ -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: [],

View File

@ -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: [],

View File

@ -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
*/

View File

@ -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: [],

View File

@ -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: [],

View File

@ -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: [],

View File

@ -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: [],

View File

@ -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: [],

View File

@ -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;
}
}
};

View File

@ -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 !== '')
<section class="agreement-workspace" aria-label="协议签署" data-agreement-signing>
<style>
@ -201,6 +205,7 @@
}
.agreement-field label,
.agreement-field-label,
.agreement-table th {
color: var(--agreement-muted);
font-size: 0.75rem;
@ -1169,43 +1174,53 @@
</div>
<section class="agreement-panel" id="agreement-panel-tasks" role="tabpanel" aria-labelledby="agreement-tab-tasks" data-agreement-panel="tasks">
<div class="agreement-filter-bar">
<form class="agreement-filter-bar" method="GET" action="{{ url()->current() }}" data-task-filter-form>
<input type="hidden" name="agreement_view" value="tasks">
<div class="agreement-field">
<label for="agreement-task-search">搜索任务</label>
<div class="agreement-search">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<input class="agreement-control" id="agreement-task-search" type="search" placeholder="任务编号、快递公司或签署人" autocomplete="off" data-task-filter>
<input class="agreement-control" id="agreement-task-search" name="agreement_search" type="search" value="{{ $agreementSearch }}" placeholder="任务编号、快递公司或签署人" autocomplete="off" data-task-filter>
</div>
</div>
<div class="agreement-field">
<label for="agreement-task-status">签署状态</label>
<select class="agreement-control" id="agreement-task-status" data-task-filter>
<select class="agreement-control" id="agreement-task-status" name="agreement_status" data-task-filter>
<option value="">全部状态</option>
<option value="draft">草稿</option>
<option value="signing">签署中</option>
<option value="completed">已完成</option>
<option value="rejected">已拒签</option>
<option value="expired">已过期</option>
<option value="prepared" @selected($agreementStatus === 'prepared')>待推送</option>
<option value="sending" @selected($agreementStatus === 'sending')>推送中</option>
<option value="signing" @selected($agreementStatus === 'signing')>待签署</option>
<option value="completed" @selected($agreementStatus === 'completed')>签署完成,待归档</option>
<option value="archived" @selected($agreementStatus === 'archived')>已签署并归档</option>
<option value="revoked" @selected($agreementStatus === 'revoked')>已撤销</option>
<option value="rejected" @selected($agreementStatus === 'rejected')>已拒签</option>
<option value="expired" @selected($agreementStatus === 'expired')>已过期</option>
<option value="failed" @selected($agreementStatus === 'failed')>处理失败</option>
<option value="unknown" @selected($agreementStatus === 'unknown')>状态未知</option>
</select>
</div>
<div class="agreement-field">
<label for="agreement-task-type">业务类型</label>
<select class="agreement-control" id="agreement-task-type" data-task-filter>
<select class="agreement-control" id="agreement-task-type" name="agreement_type" data-task-filter>
<option value="">全部类型</option>
<option value="account-change">收款账户变更</option>
<option value="account-proof">收款账户证明</option>
<option value="entity-change">公司主体变更</option>
<option value="shared-contract">共用合同证明</option>
<option value="cooperation-contract">合作合同</option>
<option value="authorization">授权委托书</option>
<option value="quote-confirmation">报价确认单</option>
<option value="account-change" @selected($agreementType === 'account-change')>收款账户变更</option>
<option value="account-proof" @selected($agreementType === 'account-proof')>收款账户证明</option>
<option value="entity-change" @selected($agreementType === 'entity-change')>公司主体变更</option>
<option value="shared-contract" @selected($agreementType === 'shared-contract')>共用合同证明</option>
<option value="cooperation-contract" @selected($agreementType === 'cooperation-contract')>合作合同</option>
<option value="authorization" @selected($agreementType === 'authorization')>授权委托书</option>
<option value="quote-confirmation" @selected($agreementType === 'quote-confirmation')>报价确认单</option>
</select>
</div>
<button class="agreement-button agreement-button--primary" type="submit">
<span class="material-icons-outlined" aria-hidden="true">search</span>
查询
</button>
<button class="agreement-button" type="button" data-task-reset>
<span class="material-icons-outlined" aria-hidden="true">restart_alt</span>
重置
</button>
</div>
</form>
<div class="agreement-table-wrap">
<table class="agreement-table">
@ -1299,8 +1314,13 @@
<span class="agreement-empty__icon">
<span class="material-icons-outlined" aria-hidden="true">assignment_late</span>
</span>
<strong>还没有协议签署任务</strong>
<p>完成E签宝接入后,发起、签署、拒签和归档记录会在这里统一追踪。</p>
@if ($agreementFiltersActive)
<strong>没有匹配的签署任务</strong>
<p>请调整筛选条件后重新查询。</p>
@else
<strong>还没有协议签署任务</strong>
<p>完成E签宝接入后,发起、签署、拒签和归档记录会在这里统一追踪。</p>
@endif
<button class="agreement-button agreement-button--primary" type="button" data-start-agreement>
<span class="material-icons-outlined" aria-hidden="true">add</span>
新建签署任务
@ -1317,10 +1337,14 @@
@php($quoteTotal = method_exists($agreementQuoteSignatures, 'total') ? $agreementQuoteSignatures->total() : $agreementQuoteSignatures->count())
@php($taskPages = method_exists($agreementTasks, 'lastPage') ? $agreementTasks->lastPage() : 0)
@php($quotePages = method_exists($agreementQuoteSignatures, 'lastPage') ? $agreementQuoteSignatures->lastPage() : 0)
@php($pageSource = $taskPages >= $quotePages ? $agreementTasks : $agreementQuoteSignatures)
<div class="agreement-panel-footnote">
<span>共 {{ $taskTotal + $quoteTotal }} 条 · 共 {{ $taskTotal + $quoteTotal > 0 ? max($taskPages, $quotePages) : 0 }} 页(每页两类签署记录各最多 50 条)</span>
@if (method_exists($pageSource, 'links')) {{ $pageSource->links() }} @endif
<span>共 {{ $taskTotal + $quoteTotal }} 条 · 协议任务 {{ $taskTotal }} 条,报价确认单 {{ $quoteTotal }} 条</span>
@if (method_exists($agreementTasks, 'links') && $taskPages > 1)
<span>协议任务分页:</span>{{ $agreementTasks->links() }}
@endif
@if (method_exists($agreementQuoteSignatures, 'links') && $quotePages > 1)
<span>报价确认单分页:</span>{{ $agreementQuoteSignatures->links() }}
@endif
</div>
<div class="agreement-panel-footnote">
<span class="material-icons-outlined" aria-hidden="true">shield</span>
@ -1404,9 +1428,22 @@
<input class="agreement-control" id="agreement-esign-account" name="esign_account" type="text" value="" data-esign-account placeholder="手机号或邮箱" autocomplete="email" required>
<p class="agreement-help">选择快递公司后自动带出工作平台的“电子签章经办人”(dzqzjbr);如资料未维护,可手工填写对方在 E 签宝实名绑定的手机号或邮箱。</p>
</div>
<div class="agreement-field agreement-field--full" data-account-signer-type-field>
<span class="agreement-field-label">对方签署主体类型 <span class="agreement-required" aria-hidden="true">*</span></span>
<div class="agreement-archive-options">
<label class="agreement-archive-choice"><input type="radio" name="counterparty_signer_type" value="organization" checked data-account-signer-type><span><strong>企业</strong><span>填写 E 签宝认证的企业全称和经办人姓名</span></span></label>
<label class="agreement-archive-choice"><input type="radio" name="counterparty_signer_type" value="person" data-account-signer-type><span><strong>个人</strong><span>填写 E 签宝实名认证的个人姓名,不需要企业全称</span></span></label>
</div>
</div>
<div class="agreement-field" data-account-organization-field>
<label for="agreement-counterparty-organization"><span data-account-signer-label>对方签署企业全称</span> <span class="agreement-required" aria-hidden="true">*</span></label>
<input class="agreement-control" id="agreement-counterparty-organization" name="counterparty_organization_name" type="text" placeholder="填写对方在 E 签宝认证的企业全称" autocomplete="organization" data-account-organization-name required>
<p class="agreement-help" data-account-signer-help>必须与对方 E 签宝企业认证名称一致;这是签署主体,不要求与银行卡持卡人姓名一致。</p>
</div>
<div class="agreement-field">
<label for="agreement-operator-name">对方签署人姓名</label>
<input class="agreement-control" id="agreement-operator-name" name="operator_name" type="text" placeholder="可选,默认使用对方签署主体名称" autocomplete="name">
<label for="agreement-operator-name">对方 E 签宝实名认证姓名 <span class="agreement-required" data-account-operator-required aria-hidden="true">*</span></label>
<input class="agreement-control" id="agreement-operator-name" name="operator_name" type="text" placeholder="填写与 E 签宝账号匹配的实名姓名" autocomplete="name" data-account-operator-name required>
<p class="agreement-help">仅用于 E 签宝身份核验,不是收款人姓名;收款人姓名请填写在下方账户资料中。</p>
</div>
<div class="agreement-field" data-quote-confirmation-field hidden>
<label for="agreement-quote-version">报价版本 <span class="agreement-required" aria-hidden="true">*</span></label>
@ -1881,6 +1918,14 @@
var subjectCodeInput = root.querySelector('[data-subject-code]');
var courierSelect = root.querySelector('[data-courier-select]');
var esignAccountInput = root.querySelector('input[name="esign_account"]');
var accountSignerTypeField = root.querySelector('[data-account-signer-type-field]');
var accountSignerTypeInputs = Array.prototype.slice.call(root.querySelectorAll('[data-account-signer-type]'));
var accountOrganizationField = root.querySelector('[data-account-organization-field]');
var accountOrganizationInput = root.querySelector('[data-account-organization-name]');
var accountSignerLabel = root.querySelector('[data-account-signer-label]');
var accountSignerHelp = root.querySelector('[data-account-signer-help]');
var operatorNameInput = root.querySelector('[data-account-operator-name]');
var operatorRequiredMark = root.querySelector('[data-account-operator-required]');
var sharedCourierList = root.querySelector('[data-shared-courier-list]');
var sharedCourierAdd = root.querySelector('[data-shared-courier-add]');
var quoteVersionField = root.querySelector('[data-quote-confirmation-field]');
@ -2389,6 +2434,44 @@
var templateType = documentOption.value;
var courier = fieldValue('[name="courier_name"]', '快递公司');
var companyName = fieldValue('[name="company_full_name"]', '待填写公司全称');
var accountAgreement = templateType === 'account-proof' || templateType === 'account-change';
var accountSignerTypeInput = accountSignerTypeInputs.find(function (input) {
return input.checked;
});
var accountSignerType = accountSignerTypeInput ? accountSignerTypeInput.value : 'organization';
var accountSignerIsPerson = accountSignerType === 'person';
if (accountSignerTypeField) {
accountSignerTypeField.hidden = !accountAgreement;
}
if (accountOrganizationField) {
accountOrganizationField.hidden = !accountAgreement;
}
if (accountOrganizationInput) {
accountOrganizationInput.disabled = !accountAgreement;
accountOrganizationInput.required = accountAgreement;
accountOrganizationInput.autocomplete = accountSignerIsPerson ? 'name' : 'organization';
accountOrganizationInput.placeholder = accountSignerIsPerson
? '填写对方在 E 签宝实名认证的姓名'
: '填写对方在 E 签宝认证的企业全称';
}
if (operatorNameInput) {
operatorNameInput.disabled = !accountAgreement || accountSignerIsPerson;
operatorNameInput.required = accountAgreement && !accountSignerIsPerson;
if (accountSignerIsPerson) {
operatorNameInput.value = accountOrganizationInput ? accountOrganizationInput.value : '';
}
}
if (operatorRequiredMark) {
operatorRequiredMark.hidden = !accountAgreement || accountSignerIsPerson;
}
if (accountSignerLabel) {
accountSignerLabel.textContent = accountSignerIsPerson ? '对方签署个人姓名' : '对方签署企业全称';
}
if (accountSignerHelp) {
accountSignerHelp.textContent = accountSignerIsPerson
? '填写与 E 签宝账号实名一致的个人姓名;该姓名会作为个人签署人发送,不需要填写企业全称。'
: '必须与对方 E 签宝企业认证名称一致;这是签署主体,不要求与银行卡持卡人姓名一致。';
}
activateTemplate(templateType);
refreshSignerAccountLabels(templateType);
var secondary = root.querySelector('[data-secondary-signer-fields]');
@ -2429,8 +2512,7 @@
setPreviewValue('proofAccountName', fieldValue('[name="account_proof_account_name"]', '待填写'));
setPreviewValue('proofBankName', fieldValue('[name="account_proof_bank_name"]', '待填写'));
setPreviewValue('proofBankAccount', fieldValue('[name="account_proof_bank_account"]', '待填写'));
var proofName = fieldValue('[name="account_proof_account_name"]', '待填写');
setPreviewValue('proofSigner', proofName + (/(公司|有限公司|有限责任|企业|集团|合作社|事务所|中心|厂)/.test(proofName) ? ' 公司盖章' : ' 签字'));
setPreviewValue('proofSigner', '公司盖章');
}
if (templateType === 'account-change') {
@ -2442,8 +2524,7 @@
setPreviewValue('newAccountName', fieldValue('[name="new_account_name"]', '待填写'));
setPreviewValue('newBankName', fieldValue('[name="new_bank_name"]', '待填写'));
setPreviewValue('newBankAccount', fieldValue('[name="new_bank_account"]', '待填写'));
var changeName = fieldValue('[name="new_account_name"]', '待填写');
setPreviewValue('changeSigner', changeName + (/(公司|有限公司|有限责任|企业|集团|合作社|事务所|中心|厂)/.test(changeName) ? ' 公司盖章' : ' 签字'));
setPreviewValue('changeSigner', '公司盖章');
}
if (templateType === 'entity-change') {
@ -2499,6 +2580,21 @@
}
}
accountSignerTypeInputs.forEach(function (input) {
input.addEventListener('change', refreshPreview);
});
if (accountOrganizationInput) {
accountOrganizationInput.addEventListener('input', function () {
var selectedType = accountSignerTypeInputs.find(function (input) {
return input.checked;
});
if (selectedType && selectedType.value === 'person' && operatorNameInput) {
operatorNameInput.value = accountOrganizationInput.value;
}
});
}
tabs.forEach(function (tab, index) {
tab.addEventListener('click', function () {
showTab(tab.dataset.agreementTab);
@ -2708,10 +2804,12 @@
});
root.querySelector('[data-task-reset]').addEventListener('click', function () {
root.querySelectorAll('[data-task-filter]').forEach(function (control) {
control.value = '';
var url = new URL(window.location.href);
['agreement_search', 'agreement_status', 'agreement_type', 'agreement_task_page', 'agreement_quote_page', 'agreement_page'].forEach(function (key) {
url.searchParams.delete(key);
});
announce('筛选条件已重置');
url.searchParams.set('agreement_view', 'tasks');
window.location.assign(url.toString());
});
window.addEventListener('popstate', function () {

View File

@ -8,6 +8,9 @@
$auditReady = (bool) ($courierSurchargeAuditReady ?? $ready);
$typeUsage = $courierSurchargeTypeUsage ?? [];
$auditLogs = $courierSurchargeAuditLogs ?? collect();
$importPreview = session('courierSurchargeImportPreview');
$importHeaders = \App\Business\CourierSurchargeQuoteWorkbookImporter::HEADERS;
$openImport = is_array($importPreview) || $errors->hasAny(['import', 'import_file', 'duplicate_policy', 'preview_token']);
$statusClass = static fn (string $status): string => $status === '已启用' ? 'is-active' : 'is-inactive';
$columns = [
['key' => 'id', 'label' => '规则编号', 'fixed' => true, 'exportable' => true],
@ -64,8 +67,38 @@
.sm-empty { padding:3rem 1rem; text-align:center; color:var(--sm-muted); } .sm-rule-note { padding:1rem 1.15rem; color:var(--sm-muted); font-size:.75rem; line-height:1.65; background:#fffdf8; }
.sm-type-manager { display:grid; gap:1rem; padding:1.15rem; } .sm-type-create { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.7rem; align-items:end; padding-bottom:1rem; border-bottom:1px solid var(--sm-border); } .sm-type-list { display:grid; gap:.55rem; } .sm-type-list__head { display:grid; gap:.2rem; color:var(--sm-muted); font-size:.72rem; line-height:1.5; } .sm-type-list__head strong { color:var(--sm-ink); font-size:.8rem; } .sm-type-row { display:flex; align-items:center; justify-content:space-between; gap:.75rem; min-height:2.6rem; padding:.5rem .65rem; border:1px solid var(--sm-border); border-radius:.45rem; background:#fffdf8; } .sm-type-row > div { display:flex; align-items:center; flex-wrap:wrap; gap:.45rem; min-width:0; } .sm-type-row small { color:var(--sm-muted); font-size:.68rem; } .sm-type-row form { flex:none; }
.sm-modal { position:fixed; inset:0; z-index:70; display:none; align-items:flex-start; justify-content:center; padding:4rem 1rem; overflow-y:auto; background:rgba(23,23,23,.42); } .sm-modal.is-open { display:flex; } .sm-modal__panel { width:min(42rem,100%); overflow:hidden; border:1px solid var(--sm-border); border-radius:.75rem; background:#fff; box-shadow:0 1rem 3rem rgba(23,23,23,.2); } .sm-modal__head { display:flex; align-items:center; justify-content:space-between; padding:1rem 1.15rem; border-bottom:1px solid var(--sm-border); } .sm-modal__head h2 { margin:0; font-size:1rem; } .sm-modal__close { border:0; background:transparent; color:var(--sm-muted); cursor:pointer; } .sm-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.8rem; padding:1.15rem; } .sm-form__field { display:grid; gap:.3rem; } .sm-form__field--full { grid-column:span 2; } .sm-form__field label,.sm-form__field legend { color:var(--sm-muted); font-size:.72rem; font-weight:600; } .sm-form__types { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.45rem; margin:0; padding:0; border:0; } .sm-type-option { display:flex; align-items:center; gap:.35rem; min-height:2.1rem; padding:.35rem .45rem; border:1px solid var(--sm-border); border-radius:.4rem; background:#fffdf8; color:var(--sm-ink); font-size:.72rem; cursor:pointer; } .sm-type-option:has(input:checked) { border-color:var(--sm-accent); background:var(--sm-soft); color:var(--sm-accent); font-weight:700; } .sm-type-option input { width:1rem; height:1rem; accent-color:var(--sm-accent); } .sm-form input,.sm-form select { height:2.25rem; } .sm-form textarea { min-height:4rem; padding:.55rem .6rem; resize:vertical; } .sm-form__foot { grid-column:span 2; display:flex; justify-content:flex-end; gap:.5rem; margin-top:.2rem; }
.sm-import-panel { width:min(76rem,100%); }
.sm-import-body { display:grid; gap:1rem; padding:1.15rem; }
.sm-import-upload { display:grid; grid-template-columns:auto minmax(14rem,1fr) auto; align-items:end; gap:.7rem; padding-bottom:1rem; border-bottom:1px solid var(--sm-border); }
.sm-import-template { align-self:center; }
.sm-import-file { display:grid; gap:.3rem; min-width:0; }
.sm-import-file label { color:var(--sm-muted); font-size:.72rem; font-weight:600; }
.sm-import-file input { width:100%; height:2.2rem; padding:.3rem .45rem; border:1px solid var(--sm-border); border-radius:.45rem; background:#fff; color:var(--sm-ink); font:inherit; }
.sm-import-help { grid-column:1 / -1; margin:0; color:var(--sm-muted); font-size:.72rem; line-height:1.55; }
.sm-import-error { padding:.7rem .8rem; border:1px solid #e2a6a6; border-radius:.45rem; background:#fff4f4; color:#8c0000; line-height:1.55; }
.sm-import-summary { display:grid; grid-template-columns:repeat(5,minmax(7rem,1fr)); border:1px solid var(--sm-border); border-radius:.5rem; overflow:hidden; }
.sm-import-summary > div { padding:.65rem .75rem; border-right:1px solid var(--sm-border); background:#fffdf8; }
.sm-import-summary > div:last-child { border-right:0; }
.sm-import-summary span { display:block; color:var(--sm-muted); font-size:.68rem; }
.sm-import-summary strong { display:block; margin-top:.15rem; font-size:1rem; font-variant-numeric:tabular-nums; }
.sm-import-summary .is-error strong { color:#b80000; }
.sm-import-preview { max-height:min(48vh,32rem); overflow:auto; border:1px solid var(--sm-border); border-radius:.45rem; scrollbar-gutter:stable both-edges; }
.sm-import-table { width:max-content; min-width:100%; border-collapse:separate; border-spacing:0; font-size:.72rem; }
.sm-import-table th,.sm-import-table td { max-width:18rem; padding:.55rem .65rem; border-right:1px solid var(--sm-border); border-bottom:1px solid var(--sm-border); text-align:left; vertical-align:top; white-space:nowrap; }
.sm-import-table th { position:sticky; top:0; z-index:2; background:var(--sm-soft); font-weight:700; }
.sm-import-table td.is-wrap { min-width:13rem; white-space:normal; line-height:1.45; }
.sm-import-status { display:inline-flex; padding:.15rem .45rem; border-radius:999px; background:#edf5e8; color:#53793e; font-size:.68rem; font-weight:700; }
.sm-import-status.is-duplicate { background:#fff4df; color:#935f07; }
.sm-import-status.is-invalid { background:#fff0f0; color:#b80000; }
.sm-import-errors { margin:.3rem 0 0; padding-left:1rem; color:#b80000; white-space:normal; }
.sm-import-policy { display:grid; gap:.45rem; padding:.8rem; border:1px solid var(--sm-border); border-radius:.45rem; background:#fffdf8; }
.sm-import-policy strong { font-size:.78rem; }
.sm-import-policy label { display:flex; align-items:flex-start; gap:.45rem; line-height:1.45; cursor:pointer; }
.sm-import-policy input { margin-top:.18rem; accent-color:var(--sm-accent); }
.sm-import-foot { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.7rem; }
.sm-import-foot p { margin:0; color:var(--sm-muted); font-size:.72rem; line-height:1.5; }
@media (max-width:1100px) { .sm-summary { grid-template-columns:repeat(3,1fr); } .sm-summary__intro { grid-column:span 3; border-bottom:1px solid var(--sm-border); } .sm-toolbar { grid-template-columns:repeat(3,minmax(0,1fr)); } .sm-toolbar > .sm-actions { grid-column:span 3; } }
@media (max-width:620px) { .sm-summary { grid-template-columns:repeat(2,1fr); } .sm-summary__intro { grid-column:span 2; } .sm-metric { border-left:0; border-top:1px solid var(--sm-border); } .sm-toolbar,.sm-form,.sm-type-create { grid-template-columns:minmax(0,1fr); } .sm-form__types { grid-template-columns:repeat(2,minmax(0,1fr)); } .sm-actions,.sm-form__field--full,.sm-form__foot { grid-column:auto; } .sm-actions .sm-button { flex:1; } }
@media (max-width:620px) { .sm-summary { grid-template-columns:repeat(2,1fr); } .sm-summary__intro { grid-column:span 2; } .sm-metric { border-left:0; border-top:1px solid var(--sm-border); } .sm-toolbar,.sm-form,.sm-type-create,.sm-import-upload { grid-template-columns:minmax(0,1fr); } .sm-form__types { grid-template-columns:repeat(2,minmax(0,1fr)); } .sm-actions,.sm-form__field--full,.sm-form__foot { grid-column:auto; } .sm-actions .sm-button { flex:1; } .sm-import-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .sm-import-summary > div { border-bottom:1px solid var(--sm-border); } }
</style>
<div class="sm-notice" role="note"><span class="material-icons-outlined" aria-hidden="true">{{ $ready ? 'verified' : 'info' }}</span><div><strong>{{ $ready ? '正式维护数据' : '数据表尚未就绪' }}</strong> {{ $ready ? '报价规则已保存到当前公司数据表,刷新页面后仍会保留;后续结算页面将按启用中的规则计算。' : ($dataReady && !$auditReady ? '操作日志表尚未完成迁移;现有报价仍可查看,新增、编辑和启停暂时禁用,避免产生无日志修改。' : '附加费报价数据表尚未完成迁移,当前不能保存规则。请由管理员在目标数据库完成迁移后再使用本页。') }}</div></div>
@ -88,7 +121,7 @@
<div class="sm-field"><label for="surcharge-type">附加费类型</label><select id="surcharge-type" name="type"><option value="">全部类型</option>@foreach (($options['filter_types'] ?? $options['types'] ?? []) as $option)<option value="{{ $option }}" @selected(($filters['type'] ?? '') === $option)>{{ $option }}</option>@endforeach</select></div>
<div class="sm-field"><label for="surcharge-mode">结算方式</label><select id="surcharge-mode" name="mode"><option value="">全部方式</option>@foreach (($options['modes'] ?? []) as $option)<option value="{{ $option }}" @selected(($filters['mode'] ?? '') === $option)>{{ $option }}</option>@endforeach</select></div>
<div class="sm-field"><label for="surcharge-status">状态</label><select id="surcharge-status" name="status"><option value="">全部状态</option>@foreach (($options['statuses'] ?? []) as $option)<option value="{{ $option }}" @selected(($filters['status'] ?? '') === $option)>{{ $option }}</option>@endforeach</select></div>
<div class="sm-actions"><button class="sm-button sm-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button><a class="sm-button" href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a></div>
<div class="sm-actions"><button class="sm-button sm-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button><a class="sm-button" href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a><button class="sm-button" type="button" data-surcharge-import-open @disabled(!$ready)><span class="material-icons-outlined" aria-hidden="true">upload_file</span>导入报价</button></div>
</form>
</div>
@ -117,7 +150,7 @@
<tr data-surcharge-empty><td data-empty-cell colspan="12"><div class="sm-empty">没有符合条件的报价规则,请点击“新增报价规则”。</div></td></tr>
@endforelse
</tbody></table></div>
<div class="sm-rule-note"><strong>维护建议:</strong>固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。当前筛选结果可导出为 CSV;本页暂未启用批量导入,避免在未确认字段规则前覆盖维护数据。</div>
<div class="sm-rule-note"><strong>维护建议:</strong>固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。批量导入必须先预览校验,已有规则需明确选择跳过或更新。</div>
</div>
<div class="sm-card" aria-label="附加费维护操作日志">
@ -146,6 +179,61 @@
@endif
</div>
<div class="sm-modal {{ $openImport ? 'is-open' : '' }}" role="dialog" aria-modal="true" aria-labelledby="surcharge-import-title" data-surcharge-import-modal data-auto-open="{{ $openImport ? 'true' : 'false' }}">
<div class="sm-modal__panel sm-import-panel">
<div class="sm-modal__head"><h2 id="surcharge-import-title">导入附加费报价</h2><button class="sm-modal__close" type="button" aria-label="关闭" data-surcharge-import-close><span class="material-icons-outlined" aria-hidden="true">close</span></button></div>
<div class="sm-import-body">
@if ($errors->hasAny(['import', 'import_file', 'duplicate_policy', 'preview_token']))
<div class="sm-import-error" role="alert">{{ $errors->first('import') ?: $errors->first('import_file') ?: $errors->first('duplicate_policy') ?: $errors->first('preview_token') }}</div>
@endif
<form class="sm-import-upload" method="POST" enctype="multipart/form-data" action="{{ route('business.courier-surcharge.import.preview') }}" data-surcharge-import-preview-form>
@csrf
<a class="sm-button sm-import-template" href="{{ route('business.courier-surcharge.import.template') }}"><span class="material-icons-outlined" aria-hidden="true">download</span>下载 Excel 模板</a>
<div class="sm-import-file"><label for="surcharge-import-file">选择已填写文件</label><input id="surcharge-import-file" type="file" name="import_file" accept=".xlsx,.xls,.csv" required data-surcharge-import-file></div>
<button class="sm-button sm-button--primary" type="submit" data-surcharge-import-preview-button><span class="material-icons-outlined" aria-hidden="true">preview</span>预览并校验</button>
<p class="sm-import-help">支持 xlsx、xls、csv,单次最多 500 条。附加费类型可用“、”分隔多项;上传只生成预览,确认后才写入。</p>
</form>
@if (is_array($importPreview))
<div class="sm-import-summary" aria-label="导入预览汇总">
<div><span>文件数据</span><strong>{{ $importPreview['summary']['total'] }}</strong></div>
<div><span>校验通过</span><strong>{{ $importPreview['summary']['valid'] }}</strong></div>
<div><span>可新增</span><strong>{{ $importPreview['summary']['new'] }}</strong></div>
<div><span>已有规则</span><strong>{{ $importPreview['summary']['duplicate'] }}</strong></div>
<div class="{{ $importPreview['summary']['invalid'] > 0 ? 'is-error' : '' }}"><span>校验失败</span><strong>{{ $importPreview['summary']['invalid'] }}</strong></div>
</div>
<div class="sm-import-preview">
<table class="sm-import-table">
<thead><tr><th scope="col">行号</th>@foreach ($importHeaders as $header)<th scope="col">{{ $header }}</th>@endforeach<th scope="col">校验结果</th></tr></thead>
<tbody>
@foreach ($importPreview['rows'] as $previewRow)
<tr>
<td>{{ $previewRow['line'] }}</td>
@foreach ($importHeaders as $header)<td class="{{ in_array($header, ['计价标准', '生效条件', '备注'], true) ? 'is-wrap' : '' }}">{{ $previewRow['display'][$header] !== '' ? $previewRow['display'][$header] : '—' }}</td>@endforeach
<td class="is-wrap"><span class="sm-import-status {{ $previewRow['result'] === 'duplicate' ? 'is-duplicate' : ($previewRow['result'] === 'invalid' ? 'is-invalid' : '') }}">{{ $previewRow['result_label'] }}</span>@if ($previewRow['errors'] !== [])<ul class="sm-import-errors">@foreach ($previewRow['errors'] as $error)<li>{{ $error }}</li>@endforeach</ul>@endif</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<form method="POST" action="{{ route('business.courier-surcharge.import.commit') }}" data-surcharge-import-commit-form>
@csrf
<input type="hidden" name="preview_token" value="{{ $importPreview['token'] }}">
@if ($importPreview['summary']['duplicate'] > 0)
<fieldset class="sm-import-policy"><strong>已有规则处理方式</strong><label><input type="radio" name="duplicate_policy" value="skip" checked><span><b>跳过已有规则</b>,只新增不存在的规则。</span></label><label><input type="radio" name="duplicate_policy" value="update"><span><b>更新已有规则</b>,使用文件中的计价标准、状态和备注覆盖对应规则。</span></label></fieldset>
@else
<input type="hidden" name="duplicate_policy" value="skip">
@endif
<div class="sm-import-foot">
<p>{{ $importPreview['summary']['invalid'] > 0 ? '存在校验失败行,修正文件并重新预览后才能导入。' : '确认导入后将写入当前公司,并记录新增、更改、启用或停用日志。' }}</p>
<div class="sm-actions"><button class="sm-button" type="button" data-surcharge-import-close>取消</button><button class="sm-button sm-button--primary" type="submit" @disabled($importPreview['summary']['invalid'] > 0) data-surcharge-import-commit><span class="material-icons-outlined" aria-hidden="true">save</span>确认导入 {{ $importPreview['summary']['valid'] }} 条</button></div>
</div>
</form>
@endif
</div>
</div>
</div>
<div class="sm-modal" role="dialog" aria-modal="true" aria-labelledby="surcharge-modal-title" data-surcharge-modal>
<div class="sm-modal__panel"><div class="sm-modal__head"><h2 id="surcharge-modal-title">新增报价规则</h2><button class="sm-modal__close" type="button" aria-label="关闭" data-surcharge-close><span class="material-icons-outlined" aria-hidden="true">close</span></button></div>
<form class="sm-form" method="POST" action="{{ route('business.courier-surcharge.store') }}" data-surcharge-form>
@ -367,18 +455,31 @@
const modal = root.querySelector('[data-surcharge-modal]');
const typeModal = root.querySelector('[data-type-modal]');
const importModal = root.querySelector('[data-surcharge-import-modal]');
const form = root.querySelector('[data-surcharge-form]');
const title = root.querySelector('#surcharge-modal-title');
const methodField = form.querySelector('[data-surcharge-method]');
const createAction = form.action;
const close = () => { modal.classList.remove('is-open'); form.reset(); form.action = createAction; methodField.value = 'POST'; title.textContent = '新增报价规则'; };
const closeType = () => { typeModal.classList.remove('is-open'); };
const closeImport = () => { importModal?.classList.remove('is-open'); };
root.querySelector('[data-surcharge-open]')?.addEventListener('click', () => { close(); modal.classList.add('is-open'); root.querySelector('#surcharge-form-courier').focus(); });
root.querySelector('[data-type-manage]')?.addEventListener('click', () => { typeModal.classList.add('is-open'); root.querySelector('#new-surcharge-type').focus(); });
root.querySelector('[data-surcharge-import-open]')?.addEventListener('click', () => { importModal?.classList.add('is-open'); root.querySelector('[data-surcharge-import-file]')?.focus(); });
root.querySelectorAll('[data-surcharge-close]').forEach((button) => button.addEventListener('click', close));
root.querySelectorAll('[data-type-close]').forEach((button) => button.addEventListener('click', closeType));
root.querySelectorAll('[data-surcharge-import-close]').forEach((button) => button.addEventListener('click', closeImport));
modal.addEventListener('click', (event) => { if (event.target === modal) close(); });
typeModal.addEventListener('click', (event) => { if (event.target === typeModal) closeType(); });
importModal?.addEventListener('click', (event) => { if (event.target === importModal) closeImport(); });
root.querySelector('[data-surcharge-import-preview-form]')?.addEventListener('submit', () => {
const button = root.querySelector('[data-surcharge-import-preview-button]');
if (button) { button.disabled = true; button.textContent = '正在校验...'; }
});
root.querySelector('[data-surcharge-import-commit-form]')?.addEventListener('submit', () => {
const button = root.querySelector('[data-surcharge-import-commit]');
if (button) { button.disabled = true; button.textContent = '正在导入...'; }
});
root.querySelectorAll('[data-surcharge-edit]').forEach((button) => button.addEventListener('click', () => {
const row = button.closest('[data-surcharge-row]'); const data = JSON.parse(row.dataset.rule); form.action = row.dataset.updateUrl; methodField.value = 'PATCH'; title.textContent = '编辑报价规则';
['courier', 'scope', 'mode', 'standard', 'condition', 'effective_at', 'reconciler', 'remark'].forEach((key) => { const field = form.elements.namedItem(key); if (field) field.value = data[key] || ''; });
@ -390,7 +491,7 @@
const selectedTypes = Array.from(form.querySelectorAll('input[name="types[]"]:checked')).map((checkbox) => checkbox.value);
if (!selectedTypes.length) { event.preventDefault(); form.querySelector('#surcharge-form-types').focus(); }
});
document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; if (modal.classList.contains('is-open')) close(); if (typeModal.classList.contains('is-open')) closeType(); if (columnPanel?.hidden === false) { columnPanel.hidden = true; columnToggle?.setAttribute('aria-expanded', 'false'); } });
document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; if (modal.classList.contains('is-open')) close(); if (typeModal.classList.contains('is-open')) closeType(); if (importModal?.classList.contains('is-open')) closeImport(); if (columnPanel?.hidden === false) { columnPanel.hidden = true; columnToggle?.setAttribute('aria-expanded', 'false'); } });
readState();
applyColumns(false);
})();

View File

@ -0,0 +1,167 @@
@php
$filters = $shippingFilters ?? [];
$sync = $shippingSync ?? ['code' => 'unavailable', 'last_succeeded_at' => null];
$statusLabels = ['succeeded' => '数据新鲜', 'running' => '同步中', 'pending' => '等待同步', 'failed' => '同步失败', 'unavailable' => '尚未同步'];
// 按工作平台发货数据界面顺序的 47 列: [列key, 表头, 对齐, 类型]
$columns = [
['express_no','快递单号','left','mono'],
['trade_no','交易单号','left','mono'],
['item_id','商品ID','left','mono'],
['barcode','条形码','left','text'],
['product_name','商品名称','left','text'],
['qty','数量','right','int'],
['receiver_state','省','left','text'],
['buyer_nick','买家昵称','left','text'],
['receiver_name','收货姓名','left','text'],
['shop_name','店铺名称','left','text'],
['express_name1','快递名称1','left','text'],
['warehouse_name','仓库','left','text'],
['ship_time','发货时间','left','text'],
['freight','运费','right','money'],
['online_weight','线上重量','right','num'],
['actual_weight','实称重量','right','num'],
['length_cm','长(cm)','right','num'],
['width_cm','宽(cm)','right','num'],
['height_cm','高(cm)','right','num'],
['volume_cm3','体积(cm³)','right','num'],
['receiver_city','市','left','text'],
['receiver_district','区','left','text'],
['supplier_name','供应商','left','text'],
['receiver_address','收货详细地址','left','text'],
['receiver_mobile','手机号码','left','text'],
['receiver_phone','座机号码','left','text'],
['express_name2','快递名称2','left','text'],
['platform','平台','left','text'],
['buyer_memo','买家留言','left','text'],
['seller_memo','卖家备注','left','text'],
['package_no','配货单号','left','mono'],
['amount','金额','right','money'],
['product_code','货号','left','text'],
['download_time','下载时间','left','text'],
['pay_time','付款时间','left','text'],
['freight_rate','费率','right','num'],
['freight_formula','公式','left','text'],
['weight_kg','重量(kg)','right','num'],
['settle_weight_kg','结算重量(kg)','right','num'],
['settle_volume_cm3','结算体积(cm³)','right','num'],
['recon_weight_kg','对账重量(kg)','right','num'],
['recon_volume_cm3','对账体积(cm³)','right','num'],
['finished_barcode','成品条码','left','text'],
['finished_name','成品名称','left','text'],
['finished_supplier','成品供应商','left','text'],
['settle_freight','结算运费','right','money'],
['front_no','换单前单号','left','mono'],
];
$fmt = function ($v, $type) {
if ($v === null || $v === '') return '';
return match ($type) {
'money' => number_format((float) $v, 2),
'num' => rtrim(rtrim(number_format((float) $v, 4, '.', ''), '0'), '.'),
'int' => (string) (int) $v,
default => (string) $v,
};
};
@endphp
<div class="shipping-data-page">
<style>
.shipping-data-page { color: #29251f; }
.shipping-data-page .sd-control { width: 100%; height: 2.125rem; border: 1px solid #d8d2c7; border-radius: .5rem; padding: 0 .625rem; font-size: .8125rem; background: #fff; color: #171717; outline: none; }
.shipping-data-page .sd-control:focus { border-color: #b18a4a; box-shadow: 0 0 0 2px rgba(177,138,74,.15); }
.shipping-data-page .sd-filter { min-width: 10rem; flex: 1 1 10rem; }
.shipping-data-page .sd-filter--wide { min-width: 12rem; flex-basis: 12rem; }
.shipping-data-page .sd-date-picker { position: relative; display: block; }
.shipping-data-page .sd-date-picker .sd-control { padding-right: 2.5rem; }
.shipping-data-page .sd-date-picker button { position: absolute; top: 0; right: 0; width: 2.125rem; height: 2.125rem; border: 0; border-left: 1px solid #d8d2c7; background: transparent; color: #856632; cursor: pointer; }
.shipping-data-page .sd-button { display: inline-flex; height: 2.125rem; align-items: center; gap: .375rem; border-radius: .5rem; padding: 0 .875rem; font-size: .8125rem; font-weight: 500; white-space: nowrap; }
.shipping-data-page .sd-button--primary { background: #856632; color: #fff; }
.shipping-data-page .sd-button--primary:hover { background: #6a5129; }
.shipping-data-page .sd-button--secondary { border: 1px solid #d8d2c7; color: #171717; background: #fff; }
.shipping-data-page .sd-button .material-icons-outlined { font-size: 1rem; }
.shipping-data-page .sd-table th { background: #f5f1ea; color: #6f675c; font-weight: 600; position: sticky; top: 0; }
.shipping-data-page .sd-table td, .shipping-data-page .sd-table th { border-bottom: 1px solid #eee8de; border-right: 1px solid #f1ece3; padding: .5rem .6rem; white-space: nowrap; font-size: .78rem; }
.shipping-data-page .sd-table tbody tr:hover { background: #fcfaf7; }
.shipping-data-page .sd-mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: .72rem; color: #4b3a24; }
.shipping-data-page .sd-scroll { overflow-x: auto; max-width: 100%; }
</style>
<div class="flex flex-col gap-4 mb-5 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 class="text-xl font-semibold text-gray-800">{{ $page['title'] ?? '发货数据' }}</h1>
<p class="mt-1 text-xs text-gray-500">数据来源:聚水潭(聚石塔/奇门)· 销售出库单 · caiwu 直连拉取 · 用于快递运费核算</p>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="rounded-full border border-[#d9d0c2] bg-white px-3 py-1.5 text-[#6f675c]">{{ $statusLabels[$sync['code'] ?? 'unavailable'] ?? '状态未知' }}</span>
@if (!empty($sync['last_succeeded_at'])) <span class="text-gray-500">最近同步 {{ $sync['last_succeeded_at'] }}</span> @endif
</div>
</div>
@if (!empty($shippingError))
<div class="mb-4 rounded border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ $shippingError }}</div>
@endif
<div class="mb-5 rounded-lg border border-[#e7e0d5] bg-white p-4 shadow-sm">
<form method="GET" class="flex flex-wrap items-end gap-3" aria-label="发货数据筛选">
<label class="sd-filter sd-filter--wide flex flex-col gap-1 text-xs text-gray-500" for="shipping-express-no"><span>快递单号</span><input id="shipping-express-no" class="sd-control" type="text" name="express_no" value="{{ $filters['express_no'] ?? '' }}" placeholder="精确匹配"></label>
<label class="sd-filter sd-filter--wide flex flex-col gap-1 text-xs text-gray-500" for="shipping-trade-no"><span>交易单号</span><input id="shipping-trade-no" class="sd-control" type="text" name="sourceno" value="{{ $filters['sourceno'] ?? '' }}" placeholder="精确匹配"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-barcode"><span>条形码</span><input id="shipping-barcode" class="sd-control" type="text" name="barcode" value="{{ $filters['barcode'] ?? '' }}" placeholder="条形码关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-supplier"><span>供应商</span><input id="shipping-supplier" class="sd-control" type="text" name="supplier_name" value="{{ $filters['supplier_name'] ?? '' }}" placeholder="名称关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-shop"><span>店铺名称</span><input id="shipping-shop" class="sd-control" type="text" name="shop_name" value="{{ $filters['shop_name'] ?? '' }}" placeholder="名称关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-warehouse"><span>仓库</span><input id="shipping-warehouse" class="sd-control" type="text" name="warehouse_name" value="{{ $filters['warehouse_name'] ?? '' }}" placeholder="仓库关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-courier"><span>快递公司</span><input id="shipping-courier" class="sd-control" type="text" name="express_company" value="{{ $filters['express_company'] ?? '' }}" placeholder="匹配快递名称1/2"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-from"><span>发货时间起</span><span class="sd-date-picker"><input id="shipping-from" class="sd-control" type="date" name="ship_from" value="{{ $filters['ship_from'] ?? '' }}"><button type="button" data-date-picker-input="shipping-from" aria-label="选择发货开始日期" title="选择发货开始日期"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button></span></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-to"><span>发货时间止</span><span class="sd-date-picker"><input id="shipping-to" class="sd-control" type="date" name="ship_to" value="{{ $filters['ship_to'] ?? '' }}"><button type="button" data-date-picker-input="shipping-to" aria-label="选择发货结束日期" title="选择发货结束日期"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button></span></label>
<div class="flex gap-2">
<button class="sd-button sd-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span><span>查询</span></button>
<a class="sd-button sd-button--secondary" href="{{ $shippingExportUrl ?? route('business.courier-payables.shipping-data.export') }}?{{ http_build_query(array_filter($filters, static fn ($value) => $value !== '')) }}" aria-label="导出当前筛选结果" title="导出当前筛选结果"><span class="material-icons-outlined" aria-hidden="true">download</span><span>导出</span></a>
<a class="sd-button sd-button--secondary" href="{{ url()->current() }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span><span>重置</span></a>
</div>
</form>
</div>
<div class="overflow-hidden rounded-lg border border-[#e7e0d5] bg-white shadow-sm">
<div class="sd-scroll">
<table class="sd-table min-w-full">
<thead><tr>
@foreach ($columns as $c)
<th class="text-{{ $c[2] }}">{{ $c[1] }}</th>
@endforeach
</tr></thead>
<tbody>
@forelse ($shippingRows ?? [] as $row)
<tr>
@foreach ($columns as $c)
@php $val = $fmt($row[$c[0]] ?? null, $c[3]); @endphp
<td class="text-{{ $c[2] }} {{ $c[3] === 'mono' ? 'sd-mono' : '' }} {{ in_array($c[3], ['num','int','money']) ? 'tabular-nums' : '' }}">{{ $val === '' ? '-' : $val }}</td>
@endforeach
</tr>
@empty
<tr><td colspan="{{ count($columns) }}" class="px-4 py-16 text-center text-sm text-gray-500">暂无发货数据(填好聚水潭凭证并运行同步后显示;或先运行演示数据 Seeder 查看界面)</td></tr>
@endforelse
</tbody>
</table>
</div>
@if (($shippingRows ?? null) instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator && $shippingRows->hasPages())
<div class="flex items-center justify-between border-t border-[#eee8de] px-4 py-3 text-sm text-gray-500"><span>共 {{ $shippingRows->total() }} 条记录</span><div>{{ $shippingRows->onEachSide(1)->links() }}</div></div>
@endif
</div>
</div>
<script>
(() => {
document.querySelectorAll('.shipping-data-page [data-date-picker-input]').forEach((trigger) => {
if (trigger.dataset.datePickerBound === 'true') return;
const input = document.getElementById(trigger.dataset.datePickerInput || '');
if (!input) return;
trigger.dataset.datePickerBound = 'true';
trigger.addEventListener('click', () => {
input.focus();
if (typeof input.showPicker === 'function') input.showPicker();
else input.click();
});
});
})();
</script>

View File

@ -4,16 +4,19 @@
$status = (string) ($onlineDaifaStatus ?? 'unknown');
$syncedAt = $onlineDaifaSyncedAt ?? null;
$summary = $onlineDaifaSummary ?? [];
$sources = $onlineDaifaSources ?? [];
$error = $onlineDaifaError ?? null;
$json = static fn (mixed $value): string => is_array($value)
? (json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) ?: '-')
: (string) ($value ?? '-');
$statusOptions = \Modules\EcommerceFinance\Services\DingPayableWorkflowStatus::filterOptions();
$statusOptions = \Modules\EcommerceFinance\Services\DingPayableWorkflowStatus::filterOptions() + [
'CONFIRMED' => '已确认(采购单)',
'FINISHED' => '已完成(采购单)',
];
$num = static fn (mixed $value): string => $value === null || $value === '' ? '-' : (string) $value;
$money = static fn (mixed $value): string => $value === null ? '-' : number_format((float) $value, 2);
$productName = static fn (array $row): string => $row['matched_product']['matched'] ?? false
? (string) ($row['matched_product']['name'] ?? '-')
: '(未匹配)';
$productName = static fn (array $row): string => ($row['source_type'] ?? 'ding') === 'purchase'
? '-'
: (($row['matched_product']['matched'] ?? false)
? (string) ($row['matched_product']['name'] ?? '-')
: '(未匹配)');
$payeeScope = trim((string) config('upstream_mirrors.ding_online_daifa_price.row_filters.payee_name', ''));
@endphp
@ -45,9 +48,6 @@
.online-daifa-table tr:hover td { background:#fcfaf4; }
.online-daifa-table .num { text-align:right; font-variant-numeric:tabular-nums; }
.online-daifa-table .unmatched { color:var(--vip-danger,#B80000); }
.online-daifa-detail { max-width:26rem; white-space:normal; }
.online-daifa-detail summary { cursor:pointer; color:var(--vip-content-accent-dark,#856632); }
.online-daifa-detail pre { max-width:36rem; max-height:18rem; overflow:auto; margin:.5rem 0 0; padding:.65rem; border:1px solid var(--vip-border,#D8D2C7); background:#faf9f6; white-space:pre-wrap; word-break:break-word; font:inherit; }
.online-daifa-empty { padding:4rem 1rem; text-align:center; color:var(--vip-content-muted,#595959); }
.online-daifa-pagination { display:flex; justify-content:space-between; padding:.75rem 1rem; color:var(--vip-content-muted,#595959); }
.online-daifa-pagination a { color:var(--vip-content-accent-dark,#856632); margin-left:.75rem; }
@ -55,7 +55,7 @@
<form method="GET" class="online-daifa-toolbar" aria-label="线上代发价格筛选">
<div class="online-daifa-field">
<label for="daifa-keyword">代发/店铺交易单号或审批编号</label>
<label for="daifa-keyword">交易单号、采购单号或审批编号</label>
<input id="daifa-keyword" name="keyword" value="{{ $filters['keyword'] ?? '' }}" placeholder="输入单据号或审批编号">
</div>
<div class="online-daifa-field">
@ -102,10 +102,13 @@
@endif
<div class="online-daifa-meta">
<span>固定流程:线上代发业务报销流程(钉钉 OA 审批)</span>
<span>数据来源:钉钉线上代发审批、聚水潭采购单管理(已确认/已完成)</span>
@if ($payeeScope !== '') <span>固定收款人:<b>{{ $payeeScope }}</b></span> @endif
<span>镜像状态:{{ $status }}</span>
@if ($syncedAt) <span>最近同步:{{ $syncedAt }}</span> @endif
@foreach ($sources as $source)
<span>{{ $source['label'] ?? '数据源' }}:<b>{{ ($source['ready'] ?? false) ? ($source['status'] ?? 'available') : '同步准备中' }}</b></span>
@endforeach
<span>共 <b>{{ $rows?->total() ?? 0 }}</b> 条</span>
</div>
@ -113,7 +116,8 @@
<div class="item"><span>代发订单金额合计</span><b>{{ $money($summary['daifa_amount'] ?? null) }}</b></div>
<div class="item"><span>订单实付金额合计</span><b>{{ $money($summary['paid_amount'] ?? null) }}</b></div>
<div class="item"><span>差价合计</span><b>{{ $money($summary['price_diff'] ?? null) }}</b></div>
<div class="item"><span>已匹配商品目录</span><b>{{ (int) ($summary['matched_count'] ?? 0) }} / {{ (int) ($summary['count'] ?? 0) }}</b></div>
<div class="item"><span>采购单管理</span><b>{{ (int) ($summary['purchase_count'] ?? 0) }}</b></div>
<div class="item"><span>已匹配商品目录</span><b>{{ (int) ($summary['matched_count'] ?? 0) }} / {{ (int) ($summary['matchable_count'] ?? $summary['count'] ?? 0) }}</b></div>
</div>
<div class="online-daifa-table-wrap">
@ -123,7 +127,7 @@
<th>商品ID</th><th>商品名称(匹配)</th><th>店铺名称</th>
<th>代发交易单号</th><th>店铺交易单号</th>
<th class="num">发货数量</th><th class="num">订单实付金额</th><th class="num">代发订单金额</th><th class="num">差价</th>
<th>供应商名称</th><th>商品编码</th><th>创建时间</th><th>原始表单</th>
<th>供应商名称</th><th>商品编码</th><th>创建时间</th>
</tr></thead>
<tbody>
@forelse ($rows ?? [] as $row)
@ -139,9 +143,9 @@
<td>{{ $row['tag'] ?? '' }}</td>
<td>{{ $num($row['product_id']) }}</td>
<td class="{{ ($row['matched_product']['matched'] ?? false) ? '' : 'unmatched' }}">{{ $productName($row) }}</td>
<td>{{ $num($row['shop_name']) }}</td>
<td class="{{ (($row['source_type'] ?? '') === 'purchase' && !($row['order_matched'] ?? false)) ? 'unmatched' : '' }}">{{ (($row['source_type'] ?? '') === 'purchase' && !($row['order_matched'] ?? false)) ? '(未匹配订单)' : $num($row['shop_name']) }}</td>
<td>{{ $num($row['daifa_trade_no']) }}</td>
<td>{{ $num($row['shop_trade_no']) }}</td>
<td class="{{ (($row['source_type'] ?? '') === 'purchase' && !($row['order_matched'] ?? false)) ? 'unmatched' : '' }}">{{ (($row['source_type'] ?? '') === 'purchase' && !($row['order_matched'] ?? false)) ? '(未匹配订单)' : $num($row['shop_trade_no']) }}</td>
<td class="num">{{ $num($row['ship_qty']) }}</td>
<td class="num">{{ $money($row['paid_amount']) }}</td>
<td class="num">{{ $money($row['daifa_amount']) }}</td>
@ -149,10 +153,9 @@
<td>{{ $num($row['supplier_name']) }}</td>
<td>{{ $num($row['material_no']) }}</td>
<td>{{ $row['workflow_created_at'] ?? '-' }}</td>
<td class="online-daifa-detail"><details><summary>展开表单({{ count($row['scheme'] ?? []) }} 项)</summary><pre>{{ $json($row['scheme'] ?? []) }}</pre></details></td>
</tr>
@empty
<tr><td colspan="19"><div class="online-daifa-empty">{{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的线上代发价格数据;同步完成后会自动显示。' }}</div></td></tr>
<tr><td colspan="18"><div class="online-daifa-empty">{{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的线上代发价格数据;同步完成后会自动显示。' }}</div></td></tr>
@endforelse
</tbody>
</table>

View File

@ -6,7 +6,7 @@
</div>
{{-- 统计卡片 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">借支总金额</div>
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalAmount) }}</div>
@ -25,8 +25,7 @@
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -35,7 +34,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="supplier" label="供应商" width="140" fixed="left" show-overflow-tooltip></el-table-column>
<el-table-column label="协议签订名称" width="140">

View File

@ -3,6 +3,7 @@
$boardDeductionFilters = $content['boardDeductionFilters'] ?? [];
$boardDeductionError = $content['boardDeductionError'] ?? null;
$boardDeductionStatus = data_get($content, 'boardDeductionStatus.X-Caiwu-Data-Status');
$boardActions = url('/' . (company_id() ?: 1) . '/workspace/supplier-payables/actions/board-deductions');
@endphp
<div class="supplier-board-page">
@ -53,6 +54,7 @@
.supplier-board-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
@ -78,6 +80,8 @@
color: #ffffff;
}
.supplier-board-button .material-icons-outlined { font-size: 1.125rem; }
.supplier-board-alert {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
@ -189,17 +193,31 @@
<label for="board-deduction-claim-number">申领单号</label>
<input id="board-deduction-claim-number" name="claim_number" type="text" value="{{ $boardDeductionFilters['claim_number'] ?? '' }}" placeholder="请输入申领单号">
</div>
<div class="supplier-board-actions">
<button type="submit" class="supplier-board-button is-primary">
<div class="supplier-board-actions supplier-page-actions">
<button type="button" class="supplier-board-button supplier-action-button" data-supplier-import-open>
<span class="material-icons-outlined" aria-hidden="true">upload_file</span><span>导入</span>
</button>
<a href="{{ $boardActions . '/export?' . http_build_query(request()->except('page')) }}" class="supplier-board-button supplier-action-button">
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出</span>
</a>
<button type="submit" class="supplier-board-button supplier-action-button is-primary">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<span>查询</span>
</button>
<a href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}" class="supplier-board-button">
<a href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}" class="supplier-board-button supplier-action-button">
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
<span>重置</span>
</a>
<button type="button" class="supplier-board-button supplier-action-button" data-supplier-maintenance-open>
<span class="material-icons-outlined" aria-hidden="true">edit</span>
<span>编辑</span>
</button>
</div>
</form>
@include('business.pages.supplier-payables.maintenance-import-modal', [
'supplierImportAction' => $boardActions . '/import',
'supplierTemplateUrl' => $boardActions . '/template',
])
@if ($boardDeductionError)
<div class="supplier-board-alert" role="alert">{{ $boardDeductionError }}</div>
@ -231,7 +249,7 @@
</thead>
<tbody>
@forelse ($boardDeductions ?? [] as $row)
<tr>
<tr data-supplier-maintenance-row data-key="{{ $row->_action_key }}" data-row="{!! e(json_encode($row, JSON_UNESCAPED_UNICODE)) !!}" tabindex="0" aria-selected="false">
<td>{{ $row->stock_in_date ?: '-' }}</td>
<td>{{ $row->stock_out_date ?: '-' }}</td>
<td>{{ $row->furniture_factory ?: '-' }}</td>
@ -277,3 +295,4 @@
</div>
</div>
</div>
@include('business.pages.supplier-payables.maintenance-blade-modal')

View File

@ -46,8 +46,7 @@
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -56,7 +55,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column prop="month" label="日期" width="100" fixed="left"></el-table-column>
<el-table-column prop="supplier" label="供应商" width="260" show-overflow-tooltip></el-table-column>
<el-table-column prop="amount" label="赔付金额" width="140" align="right">

View File

@ -1,7 +1,7 @@
<div id="delivery-unpriced-app" class="delivery-unpriced-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">未定价记录数</div>
<div class="text-2xl font-bold text-orange-500">@{{ (summary.totalCount || 0).toLocaleString() }}</div>
@ -29,8 +29,7 @@
start-placeholder="日期开始" end-placeholder="日期结束" size="small"
style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -39,7 +38,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column prop="transactionNo" label="来源单号" width="180" fixed="left" show-overflow-tooltip></el-table-column>
<el-table-column prop="distributionNo" label="单据编号(采购单号)" width="180" show-overflow-tooltip></el-table-column>
<el-table-column prop="date" label="创建时间" width="110"></el-table-column>

View File

@ -1,7 +1,7 @@
<div id="dropshipping-express-app" class="dropshipping-express-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">合计运费</div>
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalAmount) }}</div>
@ -23,8 +23,7 @@
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -33,7 +32,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="reconciliation_person" label="对账人" width="100" fixed="left"></el-table-column>
<el-table-column prop="month_str" label="日期" width="100"></el-table-column>

View File

@ -1,7 +1,7 @@
<div id="invoice-deduction-app" class="invoice-deduction-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">扣款总金额</div>
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalPrice) }}</div>
@ -20,8 +20,7 @@
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -30,7 +29,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="month_str" label="日期" width="100" fixed="left"></el-table-column>
<el-table-column label="类别" width="100">

View File

@ -0,0 +1,10 @@
<div class="supplier-page-actions">
@include('business.pages.supplier-payables.maintenance-toolbar')
<el-button size="small" type="primary" class="supplier-action-button is-primary" @click="onFilterChange">
<span class="material-icons-outlined" aria-hidden="true">search</span><span>查询</span>
</el-button>
<el-button size="small" class="supplier-action-button" @click="onReset">
<span class="material-icons-outlined" aria-hidden="true">refresh</span><span>重置</span>
</el-button>
@include('business.pages.supplier-payables.maintenance-edit-button')
</div>

View File

@ -0,0 +1,109 @@
@php
$maintenance = $content['supplierPayableMaintenance'] ?? ['dataset' => '', 'fields' => []];
$maintenanceAction = url('/' . (company_id() ?: 1) . '/workspace/supplier-payables/actions/' . $maintenance['dataset']);
@endphp
<div class="supplier-maintenance-modal" data-supplier-maintenance-modal hidden>
<div class="supplier-maintenance-modal__backdrop" data-supplier-maintenance-close></div>
<section class="supplier-maintenance-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="supplier-maintenance-modal-title">
<header class="supplier-maintenance-modal__header">
<h2 id="supplier-maintenance-modal-title">编辑记录</h2>
<button type="button" data-supplier-maintenance-close aria-label="关闭" title="关闭"><span class="material-icons-outlined">close</span></button>
</header>
<form method="POST" data-supplier-maintenance-form>
@csrf
@method('PATCH')
<div class="supplier-maintenance-modal__grid">
@foreach ($maintenance['fields'] as $field)
<label>
<span>{{ $field['label'] }}</span>
<input name="values[{{ $field['name'] }}]" data-maintenance-field="{{ $field['name'] }}"
type="{{ $field['type'] === 'number' ? 'number' : ($field['type'] === 'date' ? 'date' : ($field['type'] === 'month' ? 'month' : 'text')) }}"
@if ($field['type'] === 'number') step="0.0001" @endif>
</label>
@endforeach
</div>
<footer>
<button type="button" class="supplier-maintenance-button" data-supplier-maintenance-close>取消</button>
<button type="submit" class="supplier-maintenance-button is-primary">保存</button>
</footer>
</form>
</section>
</div>
@once
<style>
.supplier-maintenance-modal[hidden] { display: none; }
.supplier-maintenance-modal { position: fixed; inset: 0; z-index: 1050; display: grid; place-items: center; padding: 1rem; }
.supplier-maintenance-modal__backdrop { position: absolute; inset: 0; background: rgba(5, 5, 6, .58); }
.supplier-maintenance-modal__dialog { position: relative; width: min(760px, 94vw); max-height: 88vh; overflow: hidden; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; box-shadow: 0 4px 8px rgba(23, 23, 23, .10); }
.supplier-maintenance-modal__header { display: flex; align-items: center; justify-content: space-between; padding: 1rem; border-bottom: 1px solid #D8D2C7; }
.supplier-maintenance-modal__header h2 { margin: 0; color: #171717; font-size: .875rem; font-weight: 600; }
.supplier-maintenance-modal__header button { border: 0; background: transparent; color: #595959; }
.supplier-maintenance-modal__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem 1rem; max-height: 62vh; overflow-y: auto; padding: 1rem; }
.supplier-maintenance-modal__grid label { display: flex; flex-direction: column; gap: .35rem; color: #595959; font-size: .75rem; font-weight: 600; }
.supplier-maintenance-modal__grid input { height: 34px; padding: 0 10px; border: 1px solid #D8D2C7; border-radius: 8px; color: #171717; font-size: .8125rem; letter-spacing: 0; }
.supplier-maintenance-modal__grid input:focus { border-color: #856632; outline: 0; box-shadow: 0 0 0 3px rgba(133, 102, 50, .20); }
.supplier-maintenance-modal footer { display: flex; justify-content: flex-end; gap: .5rem; padding: 1rem; border-top: 1px solid #D8D2C7; }
.supplier-maintenance-button { height: 34px; padding: 0 14px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font-size: .8125rem; font-weight: 500; }
.supplier-maintenance-button.is-primary { border-color: #856632; background: #856632; color: #fff; }
[data-supplier-maintenance-row] { cursor: pointer; }
[data-supplier-maintenance-row].supplier-maintenance-row-selected { background: #F7F0DE; outline: 2px solid #856632; outline-offset: -2px; }
@media (max-width: 640px) { .supplier-maintenance-modal__grid { grid-template-columns: 1fr; } }
</style>
@endonce
@push('body_end')
<script>
(() => {
const modal = document.querySelector('[data-supplier-maintenance-modal]');
if (!modal || modal.dataset.ready) return;
modal.dataset.ready = '1';
const form = modal.querySelector('[data-supplier-maintenance-form]');
const openButton = document.querySelector('[data-supplier-maintenance-open]');
const rows = document.querySelectorAll('[data-supplier-maintenance-row]');
const base = @json($maintenanceAction);
let selectedRow = null;
let returnFocus = null;
const close = () => {
modal.hidden = true;
form.action = '';
form.reset();
returnFocus?.focus();
};
const selectRow = row => {
selectedRow?.classList.remove('supplier-maintenance-row-selected');
selectedRow?.setAttribute('aria-selected', 'false');
selectedRow = row;
selectedRow?.classList.add('supplier-maintenance-row-selected');
selectedRow?.setAttribute('aria-selected', 'true');
};
const open = () => {
if (!selectedRow || !selectedRow.dataset.key) {
window.alert('请先选择一条记录');
return;
}
const row = JSON.parse(selectedRow.dataset.row || '{}');
const recordKey = String(selectedRow.dataset.key).split(':').map(encodeURIComponent).join(':');
form.action = base + '/' + recordKey;
modal.querySelectorAll('[data-maintenance-field]').forEach(input => {
const value = row[input.dataset.maintenanceField];
input.value = value == null ? '' : value;
});
returnFocus = openButton;
modal.hidden = false;
};
rows.forEach(row => {
row.addEventListener('click', () => selectRow(row));
row.addEventListener('keydown', event => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
selectRow(row);
});
});
openButton?.addEventListener('click', open);
modal.querySelectorAll('[data-supplier-maintenance-close]').forEach(node => node.addEventListener('click', close));
document.addEventListener('keydown', event => { if (event.key === 'Escape') close(); });
})();
</script>
@endpush

View File

@ -0,0 +1,6 @@
<el-button
size="small"
class="supplier-action-button"
@click="maintenanceEditSelected">
<span class="material-icons-outlined" aria-hidden="true">edit</span><span>编辑</span>
</el-button>

View File

@ -0,0 +1,96 @@
<div class="supplier-maintenance-modal supplier-import-modal" data-supplier-import-modal hidden>
<div class="supplier-maintenance-modal__backdrop" data-supplier-import-close></div>
<section class="supplier-maintenance-modal__dialog supplier-import-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="supplier-import-modal-title">
<header class="supplier-maintenance-modal__header">
<h2 id="supplier-import-modal-title">导入数据</h2>
<button type="button" data-supplier-import-close aria-label="关闭" title="关闭"><span class="material-icons-outlined">close</span></button>
</header>
<form method="POST" enctype="multipart/form-data" action="{{ $supplierImportAction }}" data-supplier-import-form>
@csrf
<div class="supplier-import-dialog">
<a href="{{ $supplierTemplateUrl }}" class="supplier-import-template-link">
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span><strong>下载导入模板</strong><small>当前页面字段模板(Excel)</small></span>
</a>
<input type="file" name="import" accept=".xlsx,.xls,.csv" data-supplier-import-file hidden required>
<button type="button" class="supplier-import-file-button" data-supplier-import-choose>
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
<span data-supplier-import-file-name>选择导入文件</span>
</button>
</div>
<footer>
<button type="button" class="supplier-maintenance-button" data-supplier-import-close>取消</button>
<button type="submit" class="supplier-maintenance-button is-primary" data-supplier-import-submit disabled>开始导入</button>
</footer>
</form>
</section>
</div>
@once
<style>
.supplier-import-modal[hidden] { display: none; }
.supplier-import-modal { position: fixed; inset: 0; z-index: 1060; display: grid; place-items: center; padding: 1rem; }
.supplier-import-modal .supplier-maintenance-modal__backdrop { position: absolute; inset: 0; background: rgba(5, 5, 6, .58); }
.supplier-import-modal__dialog { position: relative; width: min(520px, 94vw); overflow: hidden; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; box-shadow: 0 4px 8px rgba(23, 23, 23, .10); }
.supplier-import-modal .supplier-maintenance-modal__header { display: flex; align-items: center; justify-content: space-between; padding: 1rem; border-bottom: 1px solid #D8D2C7; }
.supplier-import-modal .supplier-maintenance-modal__header h2 { margin: 0; color: #171717; font-size: .875rem; font-weight: 600; }
.supplier-import-modal .supplier-maintenance-modal__header button { border: 0; background: transparent; color: #595959; cursor: pointer; }
.supplier-import-modal .supplier-import-dialog { padding: 1rem; }
.supplier-import-modal footer { display: flex; justify-content: flex-end; gap: .5rem; padding: 1rem; border-top: 1px solid #D8D2C7; }
.supplier-import-modal .supplier-maintenance-button { height: 34px; padding: 0 14px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font-size: .8125rem; font-weight: 500; cursor: pointer; }
.supplier-import-modal .supplier-maintenance-button.is-primary { border-color: #856632; background: #856632; color: #fff; }
.supplier-import-modal [data-supplier-import-submit]:disabled { cursor: not-allowed; opacity: .5; }
</style>
@endonce
@push('body_end')
<script>
(() => {
const modal = document.querySelector('[data-supplier-import-modal]');
if (!modal || modal.dataset.ready) return;
modal.dataset.ready = '1';
const form = modal.querySelector('[data-supplier-import-form]');
const input = modal.querySelector('[data-supplier-import-file]');
const fileName = modal.querySelector('[data-supplier-import-file-name]');
const submit = modal.querySelector('[data-supplier-import-submit]');
const openButton = document.querySelector('[data-supplier-import-open]');
let returnFocus = null;
const reset = () => {
form.reset();
fileName.textContent = '选择导入文件';
submit.disabled = true;
submit.textContent = '开始导入';
delete form.dataset.submitting;
};
const close = () => {
if (form.dataset.submitting) return;
modal.hidden = true;
reset();
returnFocus?.focus();
};
openButton?.addEventListener('click', () => {
returnFocus = openButton;
modal.hidden = false;
});
modal.querySelector('[data-supplier-import-choose]')?.addEventListener('click', () => input.click());
input?.addEventListener('change', () => {
const file = input.files?.[0];
fileName.textContent = file?.name || '选择导入文件';
submit.disabled = !file;
});
form?.addEventListener('submit', event => {
if (!input.files?.length) {
event.preventDefault();
return;
}
form.dataset.submitting = '1';
submit.disabled = true;
submit.textContent = '导入中...';
});
modal.querySelectorAll('[data-supplier-import-close]').forEach(node => node.addEventListener('click', close));
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !modal.hidden) close();
});
})();
</script>
@endpush

View File

@ -0,0 +1,43 @@
<el-button size="small" class="supplier-action-button" :loading="maintenance.importing" @click="maintenanceOpenImport">
<span class="material-icons-outlined" aria-hidden="true">upload_file</span><span>导入</span>
</el-button>
<el-button size="small" class="supplier-action-button" @click="maintenanceExport">
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出</span>
</el-button>
<el-dialog title="导入数据" :visible.sync="maintenance.importVisible" width="min(520px, 92vw)" append-to-body :close-on-click-modal="!maintenance.importing" @closed="maintenanceCloseImport">
<div class="supplier-import-dialog">
<a :href="maintenanceBaseUrl + '/template'" class="supplier-import-template-link">
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span><strong>下载导入模板</strong><small>当前页面字段模板(Excel)</small></span>
</a>
<input ref="maintenanceImport" type="file" accept=".xlsx,.xls,.csv" class="supplier-maintenance-file" hidden @change="maintenanceSelectImport">
<button type="button" class="supplier-import-file-button" @click="maintenanceChooseImport">
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
<span>@{{ maintenance.importFileName || '选择导入文件' }}</span>
</button>
</div>
<span slot="footer">
<el-button :disabled="maintenance.importing" @click="maintenanceCloseImport">取消</el-button>
<el-button type="primary" :disabled="!maintenance.importFile" :loading="maintenance.importing" @click="maintenanceImport">开始导入</el-button>
</span>
</el-dialog>
<el-dialog v-if="maintenance.row" title="编辑记录" :visible.sync="maintenance.visible" width="min(760px, 92vw)" append-to-body @closed="maintenanceClose">
<el-form label-position="top" class="supplier-maintenance-form">
<el-form-item v-for="field in maintenance.fields" :key="field.name" :label="field.label">
<el-date-picker
v-if="field.type === 'date' || field.type === 'month'"
v-model="maintenance.form[field.name]"
:type="field.type"
:value-format="field.type === 'month' ? 'yyyy-MM' : 'yyyy-MM-dd'"
style="width: 100%">
</el-date-picker>
<el-input v-else v-model="maintenance.form[field.name]" :type="field.type === 'number' ? 'number' : 'text'"></el-input>
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="maintenanceClose">取消</el-button>
<el-button type="primary" :loading="maintenance.saving" @click="maintenanceSave">保存</el-button>
</span>
</el-dialog>

View File

@ -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; } }
</style>
@include('business.pages.supplier-payables.local-navigation')

View File

@ -1,7 +1,7 @@
<div id="other-adjustment-app" class="other-adjustment-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">补款总金额</div>
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalAmount) }}</div>
@ -20,8 +20,7 @@
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -30,7 +29,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="project" label="项目" width="140" fixed="left">
<template slot-scope="scope">

View File

@ -1,7 +1,7 @@
<div id="goods-reconciliation-app" class="goods-reconciliation-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">对账总金额</div>
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalAmount) }}</div>
@ -53,6 +53,7 @@
<el-option label="已发出" value="sent"></el-option>
<el-option label="未发出" value="unsent"></el-option>
</el-select>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -71,7 +72,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small"
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small"
:default-sort="{prop: 'createTime', order: 'descending'}"
@sort-change="onSortChange">
<el-table-column type="index" label="#" width="55" fixed="left"></el-table-column>

View File

@ -94,8 +94,7 @@
gap: 8px;
}
.product-price-actions .material-icons-outlined,
.product-price-edit-button .material-icons-outlined {
.product-price-actions .material-icons-outlined {
font-size: 16px;
}
@ -108,24 +107,6 @@
white-space: nowrap;
}
.product-price-edit-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 8px;
background: #ffffff;
color: var(--vip-content-accent-dark, #856632);
cursor: pointer;
}
.product-price-edit-button:hover {
border-color: var(--vip-content-accent-dark, #856632);
background: var(--vip-content-accent-soft, #F7F0DE);
}
.product-price-maintained {
display: inline-block;
margin-left: 6px;
@ -211,6 +192,16 @@
background: var(--vip-content-accent-soft, #F7F0DE);
}
.data-table tbody tr[data-product-price-row] {
cursor: pointer;
}
.data-table tbody tr.product-price-row-selected {
background: var(--vip-content-accent-soft, #F7F0DE);
outline: 2px solid var(--vip-content-accent-dark, #856632);
outline-offset: -2px;
}
.data-table tbody td {
padding: 10px 12px;
color: #171717;
@ -442,50 +433,41 @@
</div>
<div class="filter-group">
<label class="filter-label">&nbsp;</label>
<div class="product-price-actions">
<label for="product-price-import" class="btn-filter btn-filter-secondary" role="button" tabindex="0">
<div class="product-price-actions supplier-page-actions">
<button type="button" class="btn-filter btn-filter-secondary supplier-action-button" data-supplier-import-open>
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
导入
</label>
</button>
<a
href="{{ route('business.supplier-payables.product-prices.export', array_merge(['company_id' => company_id()], request()->except('page'))) }}"
class="btn-filter btn-filter-secondary"
class="btn-filter btn-filter-secondary supplier-action-button"
aria-label="导出当前筛选结果"
title="导出当前筛选结果"
>
<span class="material-icons-outlined" aria-hidden="true">download</span>
导出
</a>
<button type="submit" class="btn-filter btn-filter-primary">
<button type="submit" class="btn-filter btn-filter-primary supplier-action-button is-primary">
<span class="material-icons-outlined" aria-hidden="true">search</span>
查询
</button>
<a href="{{ route('business.pages.show', ['company_id' => company_id(), 'section' => $section['slug'], 'page' => $page['slug']]) }}"
class="btn-filter btn-filter-secondary">
class="btn-filter btn-filter-secondary supplier-action-button">
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
重置
</a>
<button type="button" class="btn-filter btn-filter-secondary supplier-action-button" data-product-price-edit-open>
<span class="material-icons-outlined" aria-hidden="true">edit</span>
编辑
</button>
</div>
</div>
</form>
<form
id="product-price-import-form"
method="POST"
action="{{ route('business.supplier-payables.product-prices.import', ['company_id' => company_id()]) }}{{ request()->getQueryString() ? '?' . request()->getQueryString() : '' }}"
enctype="multipart/form-data"
>
@csrf
<input
id="product-price-import"
class="product-price-import-input"
type="file"
name="import"
form="product-price-import-form"
accept=".xlsx,.xls,.csv"
required
>
</form>
@include('business.pages.supplier-payables.maintenance-import-modal', [
'supplierImportAction' => route('business.supplier-payables.product-prices.import', ['company_id' => company_id()]) . (request()->getQueryString() ? '?' . request()->getQueryString() : ''),
'supplierTemplateUrl' => route('business.supplier-payables.product-prices.template', ['company_id' => company_id()]),
])
@if ($errors->any() && !$errors->has('import'))
<div class="product-price-error" role="alert">
@ -521,14 +503,32 @@
<th>审批结果</th>
<th>流程标题</th>
<th>流程完成时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
@forelse ($productPrices ?? [] as $index => $item)
<tr>
<tr
data-product-price-row
data-action="{{ !empty($item->upstream_key) ? route('business.supplier-payables.product-prices.update', ['company_id' => company_id(), 'upstreamKey' => $item->upstream_key]) . (request()->getQueryString() ? '?' . request()->getQueryString() : '') : '' }}"
data-barcode="{{ $item->barcode ?? '' }}"
data-spu="{{ $item->spu ?? '' }}"
data-product-name="{{ $item->product_name ?? '' }}"
data-category="{{ $item->category ?? '' }}"
data-production-status="{{ $item->production_status ?? '' }}"
data-minimum-price="{{ $item->minimum_price ?? '' }}"
data-price-limit="{{ $item->price_limit ?? '' }}"
data-purchase-price="{{ $item->purchase_price ?? '' }}"
data-shipping-fee="{{ $item->shipping_fee ?? '' }}"
data-jingxi-limit-price="{{ $item->jingxi_limit_price ?? '' }}"
tabindex="0"
aria-selected="false">
<td class="mono">{{ ($productPrices->currentPage() - 1) * $productPrices->perPage() + $index + 1 }}</td>
<td class="mono">{{ $item->barcode ?? '-' }}</td>
<td class="mono">
{{ $item->barcode ?? '-' }}
@if ((int) ($item->is_overridden ?? 0) === 1)
<span class="product-price-maintained">已维护</span>
@endif
</td>
<td class="mono">{{ $item->spu ?? '-' }}</td>
<td>{{ $item->product_name ?? '-' }}</td>
<td>{{ $item->category ?? '-' }}</td>
@ -554,39 +554,10 @@
@endif
</td>
<td>{{ $item->completed_at ?? '-' }}</td>
<td>
@if (!empty($item->upstream_key))
<button
type="button"
class="product-price-edit-button"
aria-label="编辑 {{ $item->barcode ?? '商品价格' }}"
title="编辑"
data-product-price-edit
data-action="{{ route('business.supplier-payables.product-prices.update', ['company_id' => company_id(), 'upstreamKey' => $item->upstream_key]) }}{{ request()->getQueryString() ? '?' . request()->getQueryString() : '' }}"
data-barcode="{{ $item->barcode ?? '' }}"
data-spu="{{ $item->spu ?? '' }}"
data-product-name="{{ $item->product_name ?? '' }}"
data-category="{{ $item->category ?? '' }}"
data-production-status="{{ $item->production_status ?? '' }}"
data-minimum-price="{{ $item->minimum_price ?? '' }}"
data-price-limit="{{ $item->price_limit ?? '' }}"
data-purchase-price="{{ $item->purchase_price ?? '' }}"
data-shipping-fee="{{ $item->shipping_fee ?? '' }}"
data-jingxi-limit-price="{{ $item->jingxi_limit_price ?? '' }}"
>
<span class="material-icons-outlined" aria-hidden="true">edit</span>
</button>
@if ((int) ($item->is_overridden ?? 0) === 1)
<span class="product-price-maintained">已维护</span>
@endif
@else
-
@endif
</td>
</tr>
@empty
<tr>
<td colspan="16" class="text-center py-12">
<td colspan="15" class="text-center py-12">
<span class="material-icons-outlined text-4xl mb-2 block">inventory_2</span>
@if ($productPriceError)
数据读取失败,请稍后重试。
@ -714,73 +685,76 @@
<script>
(() => {
const importInput = document.getElementById('product-price-import');
const importForm = document.getElementById('product-price-import-form');
const importLabel = document.querySelector('label[for="product-price-import"]');
const modal = document.querySelector('[data-product-price-modal]');
const editForm = modal?.querySelector('[data-product-price-edit-form]');
const editOpen = document.querySelector('[data-product-price-edit-open]');
const rows = document.querySelectorAll('[data-product-price-row]');
let selectedRow = null;
let returnFocus = null;
importLabel?.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
importInput?.click();
}
});
importInput?.addEventListener('change', () => {
if (!importInput.files?.length) {
return;
}
if (window.confirm('确认导入并更新表格中的商品价格记录吗?')) {
importForm?.submit();
} else {
importInput.value = '';
}
});
const closeModal = () => {
if (!modal) {
return;
}
modal.hidden = true;
editForm?.reset();
if (editForm) editForm.action = '';
returnFocus?.focus();
};
document.querySelectorAll('[data-product-price-edit]').forEach((button) => {
button.addEventListener('click', () => {
if (!modal || !editForm) {
return;
const selectRow = (row) => {
selectedRow?.classList.remove('product-price-row-selected');
selectedRow?.setAttribute('aria-selected', 'false');
selectedRow = row;
selectedRow.classList.add('product-price-row-selected');
selectedRow.setAttribute('aria-selected', 'true');
};
const openEditor = () => {
if (!selectedRow?.dataset.action) {
window.alert('请先选择一条记录');
return;
}
if (!modal || !editForm) {
return;
}
returnFocus = editOpen;
editForm.action = selectedRow.dataset.action;
const values = {
barcode: selectedRow.dataset.barcode,
spu: selectedRow.dataset.spu,
product_name: selectedRow.dataset.productName,
category: selectedRow.dataset.category,
production_status: selectedRow.dataset.productionStatus,
minimum_price: selectedRow.dataset.minimumPrice,
price_limit: selectedRow.dataset.priceLimit,
purchase_price: selectedRow.dataset.purchasePrice,
shipping_fee: selectedRow.dataset.shippingFee,
jingxi_limit_price: selectedRow.dataset.jingxiLimitPrice,
};
Object.entries(values).forEach(([name, value]) => {
const input = editForm.elements.namedItem(name);
if (input) {
input.value = value || '';
}
});
returnFocus = button;
editForm.action = button.dataset.action || '';
const values = {
barcode: button.dataset.barcode,
spu: button.dataset.spu,
product_name: button.dataset.productName,
category: button.dataset.category,
production_status: button.dataset.productionStatus,
minimum_price: button.dataset.minimumPrice,
price_limit: button.dataset.priceLimit,
purchase_price: button.dataset.purchasePrice,
shipping_fee: button.dataset.shippingFee,
jingxi_limit_price: button.dataset.jingxiLimitPrice,
};
modal.hidden = false;
editForm.elements.namedItem('barcode')?.focus();
};
Object.entries(values).forEach(([name, value]) => {
const input = editForm.elements.namedItem(name);
if (input) {
input.value = value || '';
}
});
modal.hidden = false;
editForm.elements.namedItem('barcode')?.focus();
rows.forEach((row) => {
row.addEventListener('click', () => selectRow(row));
row.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
selectRow(row);
});
});
editOpen?.addEventListener('click', openEditor);
modal?.querySelectorAll('[data-product-price-close]').forEach((button) => {
button.addEventListener('click', closeModal);

View File

@ -1,7 +1,7 @@
<div id="return-reconciliation-app" class="return-reconciliation-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">对账总金额</div>
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalAmount) }}</div>
@ -29,8 +29,7 @@
start-placeholder="发货开始" end-placeholder="发货结束" size="small"
style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -39,7 +38,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="send_time" label="发货时间" width="110" fixed="left" show-overflow-tooltip></el-table-column>
<el-table-column prop="supplier" label="明细供应商" width="110" show-overflow-tooltip></el-table-column>

View File

@ -6,7 +6,7 @@
</div>
{{-- 统计卡片 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">未定价退货金额</div>
<div class="text-2xl font-bold text-orange-500">@{{ formatMoney(summary.totalAmount) }}</div>
@ -30,8 +30,7 @@
start-placeholder="日期开始" end-placeholder="日期结束" size="small"
style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -40,7 +39,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column prop="send_time" label="发货时间" width="110" fixed="left"></el-table-column>
<el-table-column prop="supplier" label="明细供应商" width="120" show-overflow-tooltip></el-table-column>
<el-table-column prop="barcode" label="条形码" width="160" show-overflow-tooltip></el-table-column>

View File

@ -1,7 +1,7 @@
<div id="sample-deduction-app" class="sample-deduction-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">合计扣款金额</div>
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalAmount) }}</div>
@ -27,8 +27,7 @@
start-placeholder="开始" end-placeholder="结束" size="small"
style="width: 240px" value-format="yyyy-MM-dd" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -37,7 +36,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="finish_time" label="日期" width="110" fixed="left"></el-table-column>
<el-table-column prop="department" label="部门" width="200" show-overflow-tooltip></el-table-column>

View File

@ -4,6 +4,7 @@
$supplierPayableError = $content['supplierPayableError'] ?? null;
$supplierPayableStatus = data_get($content, 'supplierPayableStatus.X-Caiwu-Data-Status');
$money = static fn (mixed $value): string => $value === null ? '-' : number_format((float) $value, 2);
$reportActions = url('/' . (company_id() ?: 1) . '/workspace/supplier-payables/actions/supplier-payables-report');
@endphp
<div class="supplier-payables-report-page">
@ -54,6 +55,7 @@
.supplier-payables-report-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
@ -213,20 +215,34 @@
<label for="supplier-payables-report-status">结算方式</label>
<input id="supplier-payables-report-status" name="settlement_method" type="text" value="{{ $supplierPayableFilters['settlement_method'] ?? '' }}" placeholder="请输入结算方式">
</div>
<div class="supplier-payables-report-actions">
<button type="submit" class="supplier-payables-report-button is-primary">
<div class="supplier-payables-report-actions supplier-page-actions">
<button type="button" class="supplier-payables-report-button supplier-action-button" data-supplier-import-open>
<span class="material-icons-outlined" aria-hidden="true">upload_file</span><span>导入</span>
</button>
<a href="{{ $reportActions . '/export?' . http_build_query(request()->except('page')) }}" class="supplier-payables-report-button supplier-action-button">
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出</span>
</a>
<button type="submit" class="supplier-payables-report-button supplier-action-button is-primary">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<span>查询</span>
</button>
<a
href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"
class="supplier-payables-report-button"
class="supplier-payables-report-button supplier-action-button"
>
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
<span>重置</span>
</a>
<button type="button" class="supplier-payables-report-button supplier-action-button" data-supplier-maintenance-open>
<span class="material-icons-outlined" aria-hidden="true">edit</span>
<span>编辑</span>
</button>
</div>
</form>
@include('business.pages.supplier-payables.maintenance-import-modal', [
'supplierImportAction' => $reportActions . '/import',
'supplierTemplateUrl' => $reportActions . '/template',
])
@if ($supplierPayableError)
<div class="supplier-payables-report-alert" role="alert">{{ $supplierPayableError }}</div>
@ -243,7 +259,7 @@
<col style="width: 9rem">
<col style="width: 8rem">
<col style="width: 8rem">
<col style="width: 12rem">
<col style="width: 5rem">
<col style="width: 8rem">
<col style="width: 8rem">
<col style="width: 8rem">
@ -318,7 +334,7 @@
</thead>
<tbody>
@forelse ($supplierPayables ?? [] as $index => $row)
<tr>
<tr data-supplier-maintenance-row data-key="{{ $row->_action_key }}" data-row="{!! e(json_encode($row, JSON_UNESCAPED_UNICODE)) !!}" tabindex="0" aria-selected="false">
<td>{{ $row->checker ?: '-' }}</td>
<td>{{ $row->settlement_method ?: '-' }}</td>
<td>{{ ($supplierPayables->currentPage() - 1) * $supplierPayables->perPage() + $index + 1 }}</td>
@ -381,3 +397,4 @@
</div>
</div>
</div>
@include('business.pages.supplier-payables.maintenance-blade-modal')

View File

@ -7,9 +7,12 @@ use App\Http\Controllers\Business\CourierPayableSummaries;
use App\Http\Controllers\Business\CourierSurchargeQuotes;
use App\Http\Controllers\Business\CourierSurchargeTypes;
use App\Http\Controllers\Business\ProductPrices;
use App\Http\Controllers\Business\SupplierPayableActions;
use Illuminate\Support\Facades\Route;
use Modules\EcommerceFinance\Http\Controllers\AccountBills;
use Modules\EcommerceFinance\Http\Controllers\AlipayBills;
use Modules\EcommerceFinance\Http\Controllers\DewuAccountBills;
use Modules\EcommerceFinance\Http\Controllers\PinduoduoAccountBills;
use Modules\EcommerceFinance\Http\Controllers\Api\GoodsReconciliations;
use Modules\EcommerceFinance\Http\Controllers\Api\SupplierPayables;
use Modules\EcommerceFinance\Http\Controllers\ThirdPartyWarehouseStorageSettlementExportController;
@ -26,6 +29,18 @@ Route::get('ecommerce-finance/alipay-bills/export', [AlipayBills::class, 'export
Route::get('ecommerce-finance/alipay-bills/{bill}', [AlipayBills::class, 'show'])
->name('ecommerce-finance.alipay-bills.show');
Route::get('ecommerce-finance/account-bills/type/pinduoduo', [PinduoduoAccountBills::class, 'index'])
->name('ecommerce-finance.pinduoduo-account-bills.index');
Route::get('ecommerce-finance/account-bills/type/pinduoduo/filter-options', [PinduoduoAccountBills::class, 'filterOptions'])
->name('ecommerce-finance.pinduoduo-account-bills.filter-options');
Route::get('ecommerce-finance/account-bills/type/pinduoduo/export', [PinduoduoAccountBills::class, 'export'])
->name('ecommerce-finance.pinduoduo-account-bills.export');
Route::get('ecommerce-finance/account-bills/type/dewu', [DewuAccountBills::class, 'index'])
->name('ecommerce-finance.dewu-account-bills.index');
Route::get('ecommerce-finance/account-bills/type/dewu/filter-options', [DewuAccountBills::class, 'filterOptions'])
->name('ecommerce-finance.dewu-account-bills.filter-options');
Route::get('ecommerce-finance/account-bills/type/dewu/export', [DewuAccountBills::class, 'export'])
->name('ecommerce-finance.dewu-account-bills.export');
Route::get('ecommerce-finance/account-bills/type/{bill_type}', [AccountBills::class, 'index'])
->name('ecommerce-finance.account-bills.index');
Route::get('ecommerce-finance/account-bills/type/{bill_type}/filter-options', [AccountBills::class, 'filterOptions'])
@ -138,12 +153,34 @@ Route::prefix('workspace/supplier-payables/product-prices')
->middleware('business.page:supplier-payables,product-prices')
->group(function () {
Route::get('export', [ProductPrices::class, 'export'])->name('export');
Route::get('template', [ProductPrices::class, 'template'])->name('template');
Route::post('import', [ProductPrices::class, 'import'])->middleware('import')->name('import');
Route::patch('{upstreamKey}', [ProductPrices::class, 'update'])
->where('upstreamKey', '[a-f0-9]{64}')
->name('update');
});
$supplierPayableActionPages = [
'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',
];
foreach ($supplierPayableActionPages as $dataset) {
Route::prefix('workspace/supplier-payables/actions/' . $dataset)
->middleware('business.page:supplier-payables,' . $dataset)
->group(function () use ($dataset) {
Route::get('definition', [SupplierPayableActions::class, 'definition'])->defaults('dataset', $dataset);
Route::get('export', [SupplierPayableActions::class, 'export'])->defaults('dataset', $dataset);
Route::get('template', [SupplierPayableActions::class, 'template'])->defaults('dataset', $dataset);
Route::post('import', [SupplierPayableActions::class, 'import'])->middleware('import')->defaults('dataset', $dataset);
Route::patch('{recordKey}', [SupplierPayableActions::class, 'update'])
->where('recordKey', '[A-Za-z0-9:_-]+')
->defaults('dataset', $dataset);
});
}
Route::get('workspace/refunds/non-jingxi-refund-data/export', 'Business\Pages@exportNonJingxiRefunds')
->middleware('business.page:refunds,non-jingxi-refund-data')
->name('business.refunds.non-jingxi-refund-data.export');
@ -160,6 +197,10 @@ Route::get('workspace/courier-payables/courier-information/export', 'Business\Pa
->middleware('business.page:courier-payables,courier-information')
->name('business.courier-payables.courier-information.export');
Route::get('workspace/courier-payables/shipping-data/export', 'Business\Pages@exportShippingData')
->middleware('business.page:courier-payables,shipping-data')
->name('business.courier-payables.shipping-data.export');
Route::get('workspace/courier-payables/third-party-warehouse-storage/export', 'Business\Pages@exportThirdPartyWarehouseStorage')
->middleware('business.page:courier-payables,third-party-warehouse-storage')
->name('business.courier-payables.third-party-warehouse-storage.export');
@ -194,6 +235,9 @@ Route::prefix('workspace/courier-payables/courier-surcharge')
->name('business.courier-surcharge.')
->middleware('business.page:courier-payables,courier-surcharge')
->group(function () {
Route::get('import-template', [CourierSurchargeQuotes::class, 'importTemplate'])->name('import.template');
Route::post('import/preview', [CourierSurchargeQuotes::class, 'importPreview'])->name('import.preview');
Route::post('import/commit', [CourierSurchargeQuotes::class, 'importCommit'])->name('import.commit');
Route::post('/', [CourierSurchargeQuotes::class, 'store'])->name('store');
Route::patch('{quote}', [CourierSurchargeQuotes::class, 'update'])->name('update');
Route::post('{quote}/status', [CourierSurchargeQuotes::class, 'setStatus'])->name('status');

View File

@ -39,6 +39,8 @@ trait CreatesApplication
// the shared production mirror.
config([
'database.connections.business_mirror' => config('database.connections.sqlite'),
'database.supplier_payable_maintenance_connection' => 'sqlite',
'jushuitan.connection' => 'business_mirror',
'upstream_mirrors.connection' => 'business_mirror',
]);
$app->make(DatabaseManager::class)->purge('business_mirror');

View File

@ -4,6 +4,9 @@ namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Services\Esign\EsignClient;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\HttpException;
@ -68,12 +71,21 @@ class AgreementSigningPageTest extends FeatureTestCase
->assertSee('data-subject-linked-name', false)
->assertSeeText('签署截止日期')
->assertSeeText('对方 E 签宝账号')
->assertSeeText('对方签署主体类型')
->assertSee('对方签署个人姓名', false)
->assertSee('name="counterparty_signer_type"', false)
->assertSee('data-primary-esign-label', false)
->assertSee('data-secondary-esign-label', false)
->assertSee("labels.primary = '快递方 E 签宝账号'", false)
->assertSee("labels.secondary = '我方 E 签宝账号'", false)
->assertSee("labels.primary = '委托方 E 签宝账号'", false)
->assertSee("labels.secondary = '受托方 E 签宝账号'", false)
->assertSeeText('对方签署企业全称')
->assertSeeText('必须与对方 E 签宝企业认证名称一致')
->assertSeeText('对方 E 签宝实名认证姓名')
->assertSeeText('仅用于 E 签宝身份核验,不是收款人姓名')
->assertSee("setPreviewValue('proofSigner', '公司盖章')", false)
->assertSee("setPreviewValue('changeSigner', '公司盖章')", false)
->assertSeeText('合作合同统一使用我方账号 18052733960')
->assertSeeText('填写变量')
->assertSeeText('签署完成后自动归档')
@ -120,6 +132,63 @@ class AgreementSigningPageTest extends FeatureTestCase
->assertDontSee('<td>张经理</td>', false);
}
public function testAgreementSigningPageFiltersAgreementTasksBySearchStatusAndType(): void
{
$this->grantWorkspaceAccess();
$this->createAgreementTask([
'business_no' => 'AGR-FILTER-MATCH',
'document_type' => 'account-proof',
'status' => CourierAgreementSignature::STATUS_ARCHIVED,
'courier_name' => '筛选快递',
'operator_name' => '筛选签署人',
]);
$this->createAgreementTask([
'business_no' => 'AGR-FILTER-OTHER',
'document_type' => 'account-change',
'status' => CourierAgreementSignature::STATUS_SIGNING,
'courier_name' => '另一家快递',
'operator_name' => '另一位签署人',
]);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'agreement-signing',
'agreement_search' => 'AGR-FILTER-MATCH',
'agreement_status' => CourierAgreementSignature::STATUS_ARCHIVED,
'agreement_type' => 'account-proof',
]))
->assertOk()
->assertSeeText('AGR-FILTER-MATCH')
->assertDontSeeText('AGR-FILTER-OTHER')
->assertSee('name="agreement_search"', false)
->assertSee('value="AGR-FILTER-MATCH"', false)
->assertSee('value="archived" selected', false)
->assertSee('value="account-proof" selected', false);
}
public function testAgreementSigningPageFiltersQuoteConfirmationsByVersionCarrierAndStatus(): void
{
$this->grantWorkspaceAccess();
$this->createQuoteSignature('QB-FILTER-MATCH', CourierQuoteSignature::STATUS_ARCHIVED, '筛选快递');
$this->createQuoteSignature('QB-FILTER-OTHER', CourierQuoteSignature::STATUS_SIGNING, '另一家快递');
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'agreement-signing',
'agreement_search' => 'QB-FILTER-MATCH',
'agreement_status' => CourierQuoteSignature::STATUS_ARCHIVED,
'agreement_type' => 'quote-confirmation',
]))
->assertOk()
->assertSeeText('QB-FILTER-MATCH')
->assertDontSee('<td>QB-FILTER-OTHER</td>', false)
->assertDontSeeText('没有匹配的签署任务');
}
public function testAgreementSigningPageDistinguishesDefinitiveFailureFromUnknownResult(): void
{
$this->grantWorkspaceAccess();
@ -293,4 +362,84 @@ class AgreementSigningPageTest extends FeatureTestCase
$this->assertSame(404, $exception->getStatusCode());
}
}
private function createAgreementTask(array $overrides = []): CourierAgreementSignature
{
return CourierAgreementSignature::create(array_merge([
'company_id' => $this->company->id,
'business_no' => 'AGR-FILTER-' . uniqid(),
'idempotency_key' => 'filter-' . uniqid(),
'document_type' => 'account-proof',
'template_code' => 'TPL-ACCOUNT-CERT-V2',
'template_version' => '2',
'source_file_name' => '收款账户证明文件.doc',
'status' => CourierAgreementSignature::STATUS_SIGNING,
'courier_name' => '新北中通',
'company_full_name' => $this->company->name,
'organization_name' => '新北中通',
'operator_name' => '张经理',
'operator_account' => '13800138000',
'payload' => ['signing_subject' => $this->company->name],
'environment' => 'sandbox',
'source_pdf_path' => 'courier-agreements/filter/source.pdf',
'source_pdf_sha256' => str_repeat('a', 64),
'created_by' => $this->user->id,
'updated_by' => $this->user->id,
], $overrides));
}
private function createQuoteSignature(string $versionCode, string $status, string $carrierName): CourierQuoteSignature
{
$change = CourierQuoteChange::create([
'company_id' => $this->company->id,
'number' => 'BG-' . $versionCode,
'source_quote_id' => 'QB-SOURCE-' . $versionCode,
'source_sheet_name' => $carrierName . '报价',
'carrier_name' => $carrierName,
'quote_type' => '标准报价',
'change_type' => '面单调整',
'status' => CourierQuoteChange::STATUS_APPROVED,
'effective_date' => now()->toDateString(),
'is_retrospective' => false,
'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
'summary' => '筛选测试报价。',
'created_by' => $this->user->id,
'approved_by' => $this->user->id,
'approved_at' => now(),
]);
$version = CourierQuoteVersion::create([
'company_id' => $this->company->id,
'version_code' => $versionCode,
'source_quote_id' => 'QB-SOURCE-' . $versionCode,
'source_change_id' => $change->id,
'sheet_name' => $carrierName . '报价',
'company_name' => $this->company->name,
'carrier_name' => $carrierName,
'quote_type' => '标准报价',
'effective_date' => now()->toDateString(),
'source_range' => 'A1:B2',
'values_json' => json_encode([['地区', '面单'], ['广东省', 6.5]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'formulas_json' => json_encode([[], []], JSON_THROW_ON_ERROR),
'status' => 'approved',
'signature_status' => '未签署',
'created_by' => $this->user->id,
'approved_by' => $this->user->id,
]);
return CourierQuoteSignature::create([
'company_id' => $this->company->id,
'quote_version_id' => $version->id,
'provider' => 'esign',
'environment' => 'sandbox',
'status' => $status,
'organization_name' => $carrierName,
'operator_name' => '筛选签署人',
'source_pdf_path' => 'courier-quotes/filter/' . $versionCode . '.pdf',
'source_pdf_sha256' => str_repeat('b', 64),
'source_pdf_generated_at' => now(),
'created_by' => $this->user->id,
'updated_by' => $this->user->id,
]);
}
}

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