Merge remote-tracking branch 'origin/main' into fix/chenxi-login-redirect
This commit is contained in:
commit
d36b46007f
30
.env.jushuitan.example
Normal file
30
.env.jushuitan.example
Normal 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
|
||||
@ -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
|
||||
{
|
||||
|
||||
382
app/Business/CourierSurchargeQuoteWorkbookImporter.php
Normal file
382
app/Business/CourierSurchargeQuoteWorkbookImporter.php
Normal 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 ?? '')));
|
||||
}
|
||||
}
|
||||
72
app/Console/Commands/ImportDewuSettlementBills.php
Normal file
72
app/Console/Commands/ImportDewuSettlementBills.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\DewuSettlementBillImporter;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ImportDewuSettlementBills extends Command
|
||||
{
|
||||
protected $signature = 'dewu-bills:import-authoritative
|
||||
{path : 聚胜算导出的得物完整账单 XLSX}
|
||||
{--from= : 账期开始日期,格式 YYYY-MM-DD}
|
||||
{--to= : 账期结束日期,格式 YYYY-MM-DD}
|
||||
{--expected-count= : 完整账单权威笔数}
|
||||
{--expected-income= : 完整账单权威收入}
|
||||
{--expected-expense= : 完整账单权威支出}
|
||||
{--batch=1000 : 分批写入行数}';
|
||||
|
||||
protected $description = '导入并核验聚胜算得物完整账单(保留全部渠道)';
|
||||
|
||||
public function handle(DewuSettlementBillImporter $importer): int
|
||||
{
|
||||
foreach (['from', 'to', 'expected-count', 'expected-income', 'expected-expense'] as $option) {
|
||||
if (trim((string) $this->option($option)) === '') {
|
||||
$this->error("缺少 --{$option},完整账单导入必须提供账期和三项权威控制数。");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('path'),
|
||||
(string) $this->option('from'),
|
||||
(string) $this->option('to'),
|
||||
(int) $this->option('expected-count'),
|
||||
(string) $this->option('expected-income'),
|
||||
(string) $this->option('expected-expense'),
|
||||
(int) $this->option('batch'),
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('得物完整账单导入失败,详细原因已记录到受保护日志。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['状态', '账单行数', '跳过行数', '店铺数', '账单项目数', '收入', '支出', '净额', '账期'], [[
|
||||
$result['status'],
|
||||
$result['row_count'],
|
||||
$result['excluded_rows'],
|
||||
$result['store_count'],
|
||||
$result['bill_item_count'],
|
||||
$result['income_total'],
|
||||
$result['expense_total'],
|
||||
$result['net_total'],
|
||||
$result['period_start'] . ' 至 ' . $result['period_end'],
|
||||
]]);
|
||||
$this->line('渠道:' . collect($result['channel_counts'])
|
||||
->map(fn (int $count, string $channel): string => "{$channel} {$count}")
|
||||
->implode(';'));
|
||||
$this->line('SHA-256: ' . $result['source_sha256']);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -9,21 +9,22 @@ use Throwable;
|
||||
class ImportDouyinAuthoritativeBills extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:import-douyin-authoritative
|
||||
{path : 胜算原始明细 UTF-8 CSV} {month : 完整月份 YYYY-MM}
|
||||
{--count= : 核对笔数} {--income= : 核对收入合计} {--expense= : 核对支出合计}';
|
||||
{path : 胜算原始明细 UTF-8 CSV} {month : 所属月份 YYYY-MM}
|
||||
{--count= : 核对笔数} {--income= : 核对收入合计} {--expense= : 核对支出合计}
|
||||
{--through= : 月初至此日期的完整数据 YYYY-MM-DD,省略时为整月}';
|
||||
|
||||
protected $description = '校验并原子启用完整月份胜算抖音明细,保留旧版本用于回滚';
|
||||
protected $description = '校验并原子启用指定月份或月初至截止日的胜算抖音明细,保留旧版本用于回滚';
|
||||
|
||||
public function handle(DouyinAuthoritativeBillImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import((string) $this->argument('path'), (string) $this->argument('month'),
|
||||
(int) $this->option('count'), (string) $this->option('income'), (string) $this->option('expense'));
|
||||
(int) $this->option('count'), (string) $this->option('income'), (string) $this->option('expense'), $this->option('through'));
|
||||
} catch (Throwable $e) {
|
||||
$this->error('胜算抖音明细未启用,保留上一版数据。' . ($e instanceof \InvalidArgumentException ? $e->getMessage() : '请检查文件和镜像连接。'));
|
||||
return self::FAILURE;
|
||||
}
|
||||
$this->info('胜算抖音明细已启用,月份:' . $this->argument('month') . ',笔数:' . $result['row_count']);
|
||||
$this->info('胜算抖音明细已启用,范围:[' . $result['period_start'] . ', ' . $result['period_end'] . '),笔数:' . $result['row_count']);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
72
app/Console/Commands/ImportPinduoduoSettlementBills.php
Normal file
72
app/Console/Commands/ImportPinduoduoSettlementBills.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\PinduoduoSettlementBillImporter;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ImportPinduoduoSettlementBills extends Command
|
||||
{
|
||||
protected $signature = 'pinduoduo-bills:import-authoritative
|
||||
{path : 聚胜算导出的拼多多完整账单 XLSX}
|
||||
{--from= : 账期开始日期,格式 YYYY-MM-DD}
|
||||
{--to= : 账期结束日期,格式 YYYY-MM-DD}
|
||||
{--expected-count= : 完整账单权威笔数}
|
||||
{--expected-income= : 完整账单权威收入}
|
||||
{--expected-expense= : 完整账单权威支出}
|
||||
{--batch=1000 : 分批写入行数}';
|
||||
|
||||
protected $description = '导入并核验聚胜算拼多多完整账单(保留全部账户类型)';
|
||||
|
||||
public function handle(PinduoduoSettlementBillImporter $importer): int
|
||||
{
|
||||
foreach (['from', 'to', 'expected-count', 'expected-income', 'expected-expense'] as $option) {
|
||||
if (trim((string) $this->option($option)) === '') {
|
||||
$this->error("缺少 --{$option},完整账单导入必须提供账期和三项权威控制数。");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('path'),
|
||||
(string) $this->option('from'),
|
||||
(string) $this->option('to'),
|
||||
(int) $this->option('expected-count'),
|
||||
(string) $this->option('expected-income'),
|
||||
(string) $this->option('expected-expense'),
|
||||
(int) $this->option('batch'),
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('拼多多完整货款账单导入失败,详细原因已记录到受保护日志。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['状态', '账单行数', '跳过行数', '店铺数', '账单项目数', '收入', '支出', '净额', '账期'], [[
|
||||
$result['status'],
|
||||
$result['row_count'],
|
||||
$result['excluded_account_rows'],
|
||||
$result['store_count'],
|
||||
$result['bill_item_count'],
|
||||
$result['income_total'],
|
||||
$result['expense_total'],
|
||||
$result['net_total'],
|
||||
$result['period_start'] . ' 至 ' . $result['period_end'],
|
||||
]]);
|
||||
$this->line('账户类型:' . collect($result['account_type_counts'])
|
||||
->map(fn (int $count, string $account): string => "{$account} {$count}")
|
||||
->implode(';'));
|
||||
$this->line('SHA-256: ' . $result['source_sha256']);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,7 @@ class SyncProductPurchasePriceMirror extends Command
|
||||
{--batch= : 每批最多读取行数}
|
||||
{--max-batches= : 本轮最多批次数,0 表示直到完成}';
|
||||
|
||||
protected $description = '同步采购单管理的普通商品单价,并保留可断点的共享业务镜像';
|
||||
protected $description = '同步采购单管理明细和普通商品单价,并保留可断点的共享业务镜像';
|
||||
|
||||
public function handle(
|
||||
ProductPurchasePriceMirrorRepository $mirror,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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'])),
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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' => '供应商应付数据暂时无法读取,请稍后重试。',
|
||||
];
|
||||
|
||||
@ -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(
|
||||
|
||||
196
app/Http/Controllers/Business/SupplierPayableActions.php
Normal file
196
app/Http/Controllers/Business/SupplierPayableActions.php
Normal file
@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository;
|
||||
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
|
||||
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableMaintenanceRegistry;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableImportTemplate;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableWorkbookImporter;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class SupplierPayableActions extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SupplierPayableRepository $supplier,
|
||||
private readonly GoodsReconciliationRepository $goods,
|
||||
private readonly SupplierPayableReportRepository $reports,
|
||||
private readonly SupplierPayableMaintenanceRegistry $registry,
|
||||
private readonly SupplierPayableImportTemplate $template,
|
||||
private readonly SupplierPayableOverrideStore $overrides,
|
||||
private readonly SupplierPayableWorkbookImporter $importer,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
}
|
||||
|
||||
public function definition(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json($this->registry->publicDefinition($this->dataset($request)));
|
||||
}
|
||||
|
||||
public function update(Request $request, string $recordKey): JsonResponse|RedirectResponse
|
||||
{
|
||||
$dataset = $this->dataset($request);
|
||||
$values = $this->registry->normalize($dataset, (array) $request->input('values', []));
|
||||
$result = $this->overrides->save(
|
||||
$dataset,
|
||||
$recordKey,
|
||||
$values,
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['message' => '保存成功。'] + $result)
|
||||
: back()->with('success', '保存成功。');
|
||||
}
|
||||
|
||||
public function import(Request $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
$request->validate(['import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240']]);
|
||||
$result = $this->importer->import(
|
||||
$this->dataset($request),
|
||||
$request->file('import'),
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
$message = "导入完成:新增维护 {$result['created']} 条,更新维护 {$result['updated']} 条。";
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['message' => $message] + $result)
|
||||
: back()->with('success', $message);
|
||||
}
|
||||
|
||||
public function template(Request $request): StreamedResponse
|
||||
{
|
||||
$dataset = $this->dataset($request);
|
||||
|
||||
return $this->template->download($this->title($dataset), $this->registry->fields($dataset));
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$dataset = $this->dataset($request);
|
||||
$fields = $this->registry->fields($dataset);
|
||||
$filters = $this->filters($dataset, $request);
|
||||
$companyId = (int) (company_id() ?: 1);
|
||||
|
||||
return response()->streamDownload(function () use ($dataset, $fields, $filters, $companyId): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
if ($csv === false) {
|
||||
throw new RuntimeException('无法创建导出文件。');
|
||||
}
|
||||
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
fputcsv($csv, array_merge(['记录标识'], array_column($fields, 'label')));
|
||||
|
||||
foreach ($this->allRows($dataset, $filters) as $batch) {
|
||||
foreach ($this->overrides->decorate($dataset, $batch, $companyId) as $row) {
|
||||
$values = [$this->csvText($row['_action_key'] ?? '')];
|
||||
foreach ($fields as $field => $definition) {
|
||||
$values[] = $definition['type'] === 'number' && is_numeric($row[$field] ?? null)
|
||||
? $row[$field]
|
||||
: $this->csvText($row[$field] ?? '');
|
||||
}
|
||||
fputcsv($csv, $values);
|
||||
}
|
||||
}
|
||||
fclose($csv);
|
||||
}, $this->title($dataset) . '_' . now()->format('Ymd_His') . '.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return iterable<array<int,array<string,mixed>>> */
|
||||
private function allRows(string $dataset, array $filters): iterable
|
||||
{
|
||||
if ($dataset === 'payment-reconciliation' && ($filters['record_type'] ?? 'all') === 'all') {
|
||||
foreach (['purchase', 'sale'] as $type) {
|
||||
yield from $this->allRows($dataset, array_replace($filters, ['record_type' => $type]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$page = 1;
|
||||
do {
|
||||
$result = $this->page($dataset, $filters, $page, 100);
|
||||
$rows = array_slice((array) ($result['data'] ?? $result['rows'] ?? []), 0, 100);
|
||||
if ($rows !== []) {
|
||||
yield $rows;
|
||||
}
|
||||
$total = (int) ($result['total'] ?? -1);
|
||||
$more = $rows !== [] && ($total < 0 ? count($rows) === 100 : $page * 100 < $total);
|
||||
$page++;
|
||||
} while ($more);
|
||||
}
|
||||
|
||||
private function page(string $dataset, array $filters, int $page, int $limit): array
|
||||
{
|
||||
return match ($dataset) {
|
||||
'payment-reconciliation' => $this->goods->list($filters, $page, $limit),
|
||||
'return-reconciliation' => $this->supplier->returnReconciliations($filters, $page, $limit),
|
||||
'invoice-deductions' => $this->supplier->invoiceDeductions($filters, $page, $limit),
|
||||
'compensation-details' => $this->supplier->compensationDetails($filters, $page, $limit),
|
||||
'advance-details' => $this->supplier->advanceDetails($filters, $page, $limit),
|
||||
'sample-deductions' => $this->supplier->sampleDeductions($filters, $page, $limit),
|
||||
'delivery-unpriced' => $this->supplier->deliveryUnpriced($filters, $page, $limit),
|
||||
'return-unpriced' => $this->supplier->returnUnpriced($filters, $page, $limit),
|
||||
'other-adjustments' => $this->supplier->otherAdjustments($filters, $page, $limit),
|
||||
'dropshipping-express' => $this->supplier->dropshippingExpress($filters, $page, $limit),
|
||||
'board-deductions' => $this->reports->boardDeductions($filters, $page, $limit),
|
||||
'supplier-payables-report' => $this->reports->supplierPayables($filters, $page, $limit),
|
||||
};
|
||||
}
|
||||
|
||||
private function filters(string $dataset, Request $request): array
|
||||
{
|
||||
$allowed = match ($dataset) {
|
||||
'payment-reconciliation' => ['record_type', 'supplier', 'warehouse', 'stock_no', 'purchase_no', 'product_code', 'express_no', 'send_time_from', 'send_time_to', 'create_time_from', 'create_time_to', 'sent_status', 'has_diff'],
|
||||
'return-reconciliation', 'return-unpriced' => ['supplier', 'warehouse', 'sku_id', 'express_no', 'date_from', 'date_to'],
|
||||
'invoice-deductions', 'compensation-details', 'advance-details', 'other-adjustments' => ['supplier', 'month_from', 'month_to'],
|
||||
'sample-deductions' => ['supplier', 'department', 'creator', 'sku_id', 'date_from', 'date_to'],
|
||||
'delivery-unpriced' => ['supplier', 'warehouse', 'purchase', 'barcode', 'express_no', 'date_from', 'date_to'],
|
||||
'dropshipping-express' => ['supplier', 'express_name', 'month_from', 'month_to'],
|
||||
'board-deductions' => ['stock_in_date', 'stock_out_date', 'furniture_factory', 'lamination_factory', 'deduction_type', 'material_name', 'claim_number'],
|
||||
'supplier-payables-report' => ['month', 'supplier', 'checker', 'settlement_method'],
|
||||
};
|
||||
|
||||
return collect($request->only($allowed))
|
||||
->map(static fn (mixed $value): mixed => is_string($value) ? trim($value) : $value)
|
||||
->reject(static fn (mixed $value): bool => $value === '' || $value === null || $value === false)
|
||||
->all();
|
||||
}
|
||||
|
||||
private function dataset(Request $request): string
|
||||
{
|
||||
$dataset = (string) $request->route('dataset');
|
||||
$this->registry->fields($dataset);
|
||||
return $dataset;
|
||||
}
|
||||
|
||||
private function title(string $dataset): string
|
||||
{
|
||||
return [
|
||||
'payment-reconciliation' => '货款对账', 'return-reconciliation' => '退货对账',
|
||||
'invoice-deductions' => '发票扣款', 'compensation-details' => '赔偿明细',
|
||||
'advance-details' => '借支明细', 'sample-deductions' => '样品扣款',
|
||||
'delivery-unpriced' => '送货未定价', 'return-unpriced' => '退货未定价',
|
||||
'other-adjustments' => '其他扣款补款', 'dropshipping-express' => '代发快递',
|
||||
'board-deductions' => '板材扣款', 'supplier-payables-report' => '供应商应付表',
|
||||
][$dataset];
|
||||
}
|
||||
|
||||
private function csvText(mixed $value): string
|
||||
{
|
||||
$value = (string) $value;
|
||||
return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value;
|
||||
}
|
||||
}
|
||||
@ -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) ?: '协议签署';
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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' => '赔偿明细'],
|
||||
|
||||
@ -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
46
config/jushuitan.php
Normal 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)
|
||||
],
|
||||
];
|
||||
@ -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' => [
|
||||
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection(config('database.supplier_payable_maintenance_connection'));
|
||||
|
||||
if (! $schema->hasTable('supplier_payable_overrides')) {
|
||||
$schema->create('supplier_payable_overrides', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedInteger('company_id');
|
||||
$table->string('dataset', 64);
|
||||
$table->string('record_key', 191);
|
||||
$table->json('values');
|
||||
$table->unsignedInteger('created_by')->nullable();
|
||||
$table->unsignedInteger('updated_by')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['company_id', 'dataset', 'record_key'], 'supplier_payable_override_unique');
|
||||
$table->index(['company_id', 'dataset'], 'supplier_payable_override_dataset');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('supplier_payable_override_events')) {
|
||||
$schema->create('supplier_payable_override_events', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedInteger('company_id');
|
||||
$table->string('dataset', 64);
|
||||
$table->string('record_key', 191);
|
||||
$table->string('source', 24);
|
||||
$table->json('before_values')->nullable();
|
||||
$table->json('after_values');
|
||||
$table->unsignedInteger('user_id')->nullable();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->index(['company_id', 'dataset', 'record_key'], 'supplier_payable_override_event_lookup');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection(config('database.supplier_payable_maintenance_connection'));
|
||||
$schema->dropIfExists('supplier_payable_override_events');
|
||||
$schema->dropIfExists('supplier_payable_overrides');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
68
database/seeds/JushuitanShippingDemoSeeder.php
Normal file
68
database/seeds/JushuitanShippingDemoSeeder.php
Normal 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 起)。');
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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` 的工作平台价格记录混为同一来源。
|
||||
|
||||
|
||||
59
docs/得物完整账单取数方案留档.md
Normal file
59
docs/得物完整账单取数方案留档.md
Normal 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 与控制数,再调用受控导入命令。源文件未生成、下载失败、会话失效、控制数不一致、生产连接不可用或迁移未完成时停止,不使用旧镜像补数。
|
||||
|
||||
上线前需单独完成生产备份、迁移授权、发布以及首次导入授权。回滚时使用已归档的上一版源文件及其控制数重新导入相同账期。
|
||||
@ -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'],
|
||||
|
||||
37
modules/EcommerceFinance/Console/SyncJushuitanShipping.php
Normal file
37
modules/EcommerceFinance/Console/SyncJushuitanShipping.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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' => '得物',
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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'],
|
||||
|
||||
178
modules/EcommerceFinance/Http/Controllers/DewuAccountBills.php
Normal file
178
modules/EcommerceFinance/Http/Controllers/DewuAccountBills.php
Normal 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, '当前账号没有店铺收款账户账单权限。');
|
||||
}
|
||||
}
|
||||
@ -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, '当前账号没有店铺收款账户账单权限。');
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 '
|
||||
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
731
modules/EcommerceFinance/Services/DewuSettlementBillImporter.php
Normal file
731
modules/EcommerceFinance/Services/DewuSettlementBillImporter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
130
modules/EcommerceFinance/Services/Jushuitan/QimenClient.php
Normal file
130
modules/EcommerceFinance/Services/Jushuitan/QimenClient.php
Normal 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},已拒绝请求(不会同步任何数据)。");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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] ?? '';
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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'));
|
||||
}
|
||||
}
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
4
resources/assets/js/views/business/pages.js
vendored
4
resources/assets/js/views/business/pages.js
vendored
@ -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);
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
166
resources/assets/js/views/ecommerce-finance/supplier-payable-maintenance.js
vendored
Normal file
166
resources/assets/js/views/ecommerce-finance/supplier-payable-maintenance.js
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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 () {
|
||||
|
||||
@ -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);
|
||||
})();
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
@ -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
|
||||
@ -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>
|
||||
@ -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
|
||||
@ -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>
|
||||
@ -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')
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"> </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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user