Merge remote-tracking branch 'origin/main' into fix/limeng-agreement-signing-payee-separation
# Conflicts: # tests/Feature/Business/AgreementSigningPageTest.php # tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php
This commit is contained in:
commit
de2ded20b4
120
app/Business/CourierPayableSummaryFilter.php
Normal file
120
app/Business/CourierPayableSummaryFilter.php
Normal file
@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class CourierPayableSummaryFilter
|
||||
{
|
||||
/** @return array{date:string,person:string,courier:string,keyword:string} */
|
||||
public function fromRequest(Request $request): array
|
||||
{
|
||||
$clean = static function (string $key, int $maxLength) use ($request): string {
|
||||
$value = $request->query($key, '');
|
||||
|
||||
if (! is_scalar($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return mb_substr(trim((string) $value), 0, $maxLength);
|
||||
};
|
||||
|
||||
return [
|
||||
'date' => $clean('date', 50),
|
||||
'person' => $clean('person', 100),
|
||||
'courier' => $clean('courier', 100),
|
||||
'keyword' => $clean('keyword', 191),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array{date:string,person:string,courier:string,keyword:string} $filters */
|
||||
public function hasFilters(array $filters): bool
|
||||
{
|
||||
return collect($filters)->contains(static fn (string $value): bool => $value !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed> $rows
|
||||
* @param list<string> $headers
|
||||
* @param array{date:string,person:string,courier:string,keyword:string} $filters
|
||||
* @return Collection<int, mixed>
|
||||
*/
|
||||
public function apply(Collection $rows, array $headers, array $filters): Collection
|
||||
{
|
||||
if (! $this->hasFilters($filters)) {
|
||||
return $rows;
|
||||
}
|
||||
|
||||
$dateColumn = $this->columnIndex($headers, ['日期', '结算月份', '结算月', '月份']);
|
||||
$personColumn = $this->columnIndex($headers, ['负责人', '责任人']);
|
||||
$courierColumn = $this->columnIndex($headers, ['快递公司', '快递名称', '快递名', '快递']);
|
||||
|
||||
return $rows->filter(function (mixed $row) use ($filters, $dateColumn, $personColumn, $courierColumn): bool {
|
||||
if (! is_array($row)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->columnMatches($row, $dateColumn, $filters['date'])
|
||||
|| ! $this->columnMatches($row, $personColumn, $filters['person'])
|
||||
|| ! $this->columnMatches($row, $courierColumn, $filters['courier'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($filters['keyword'] === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($row as $value) {
|
||||
if ($this->contains($value, $filters['keyword'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param list<string> $headers */
|
||||
public function dateColumnIndex(array $headers): ?int
|
||||
{
|
||||
return $this->columnIndex($headers, ['日期', '结算月份', '结算月', '月份']);
|
||||
}
|
||||
|
||||
/** @param list<string> $headers */
|
||||
private function columnIndex(array $headers, array $aliases): ?int
|
||||
{
|
||||
$normalizedAliases = array_map(
|
||||
static fn (string $alias): string => mb_strtolower(preg_replace('/[\\s ]+/u', '', $alias) ?? $alias),
|
||||
$aliases,
|
||||
);
|
||||
|
||||
foreach ($headers as $index => $header) {
|
||||
$normalizedHeader = mb_strtolower(preg_replace('/[\\s ]+/u', '', trim((string) $header)) ?? trim((string) $header));
|
||||
|
||||
if (in_array($normalizedHeader, $normalizedAliases, true)) {
|
||||
return (int) $index;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function columnMatches(array $row, ?int $column, string $needle): bool
|
||||
{
|
||||
if ($needle === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $column !== null && $this->contains($row[$column] ?? '', $needle);
|
||||
}
|
||||
|
||||
private function contains(mixed $value, string $needle): bool
|
||||
{
|
||||
if (! is_scalar($value) && ! $value instanceof \Stringable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return mb_stripos((string) $value, $needle, 0, 'UTF-8') !== false;
|
||||
}
|
||||
}
|
||||
@ -16,11 +16,16 @@ class CourierPayableSummaryStore
|
||||
public const PAGES = [
|
||||
'freight-difference' => '运费差',
|
||||
'other-surcharge-summary' => '其他附加费汇总',
|
||||
'return-fee' => '退件费',
|
||||
];
|
||||
|
||||
private const MAX_COLUMNS = 200;
|
||||
|
||||
private const MAX_ROWS = 10000;
|
||||
private const MAX_ROWS_BY_PAGE = [
|
||||
'freight-difference' => 500_000,
|
||||
'other-surcharge-summary' => 500_000,
|
||||
'return-fee' => 500_000,
|
||||
];
|
||||
|
||||
private const MAX_CELL_LENGTH = 10000;
|
||||
|
||||
@ -31,6 +36,13 @@ class CourierPayableSummaryStore
|
||||
return self::PAGES[$page];
|
||||
}
|
||||
|
||||
public function maxRows(string $page): int
|
||||
{
|
||||
$this->title($page);
|
||||
|
||||
return self::MAX_ROWS_BY_PAGE[$page];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{version:int,headers:list<string>,rows:list<list<string>>,source_name:string,imported_at:string,imported_by:int|null}|null
|
||||
*/
|
||||
@ -72,7 +84,7 @@ class CourierPayableSummaryStore
|
||||
$reader = IOFactory::createReaderForFile($file->getRealPath());
|
||||
$reader->setReadDataOnly(true);
|
||||
$spreadsheet = $reader->load($file->getRealPath());
|
||||
[$headers, $rows] = $this->readSpreadsheet($spreadsheet);
|
||||
[$headers, $rows] = $this->readSpreadsheet($spreadsheet, $page);
|
||||
} catch (ValidationException $exception) {
|
||||
throw $exception;
|
||||
} catch (Throwable $exception) {
|
||||
@ -128,8 +140,10 @@ class CourierPayableSummaryStore
|
||||
}
|
||||
|
||||
/** @return array{0:list<string>,1:list<list<string>>} */
|
||||
private function readSpreadsheet($spreadsheet): array
|
||||
private function readSpreadsheet($spreadsheet, string $page): array
|
||||
{
|
||||
$maxRows = $this->maxRows($page);
|
||||
|
||||
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
|
||||
$rawRows = $worksheet->toArray(null, false, true, false);
|
||||
$headerIndex = $this->headerIndex($rawRows);
|
||||
@ -169,9 +183,9 @@ class CourierPayableSummaryStore
|
||||
|
||||
$rows[] = $row;
|
||||
|
||||
if (count($rows) > self::MAX_ROWS) {
|
||||
if (count($rows) > $maxRows) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '单次最多导入 ' . number_format(self::MAX_ROWS) . ' 条汇总数据,请拆分文件后重试。',
|
||||
'import' => '单次最多导入 ' . number_format($maxRows) . ' 条汇总数据,请拆分文件后重试。',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
148
app/Business/CourierSurchargeAuditLogRepository.php
Normal file
148
app/Business/CourierSurchargeAuditLogRepository.php
Normal file
@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\CourierSurchargeAuditLog;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 快递附加费维护审计日志的固定入口。
|
||||
*
|
||||
* 日志只保存当前公司、操作人和报价/类型的安全字段快照,不保存密码、
|
||||
* Token、连接串或原始请求内容。
|
||||
*/
|
||||
class CourierSurchargeAuditLogRepository
|
||||
{
|
||||
/** @var array<string,string> */
|
||||
private const FIELD_LABELS = [
|
||||
'courier' => '快递名称',
|
||||
'scope' => '快递归属',
|
||||
'reconciler' => '对账人',
|
||||
'types' => '附加费类型',
|
||||
'mode' => '结算方式',
|
||||
'standard' => '计价标准',
|
||||
'condition' => '生效条件',
|
||||
'effective_at' => '生效日期',
|
||||
'remark' => '备注',
|
||||
'status' => '状态',
|
||||
'name' => '类型名称',
|
||||
];
|
||||
|
||||
/** @var array<string,string> */
|
||||
private const ACTION_LABELS = [
|
||||
CourierSurchargeAuditLog::ACTION_CREATED => '新增',
|
||||
CourierSurchargeAuditLog::ACTION_UPDATED => '更改',
|
||||
CourierSurchargeAuditLog::ACTION_ACTIVATED => '启用',
|
||||
CourierSurchargeAuditLog::ACTION_DEACTIVATED => '停用',
|
||||
];
|
||||
|
||||
/** @var array<string,string> */
|
||||
private const TARGET_LABELS = [
|
||||
CourierSurchargeAuditLog::TARGET_QUOTE => '报价规则',
|
||||
CourierSurchargeAuditLog::TARGET_TYPE => '附加费类型',
|
||||
];
|
||||
|
||||
public function ready(): bool
|
||||
{
|
||||
try {
|
||||
return Schema::hasTable((new CourierSurchargeAuditLog())->getTable());
|
||||
} catch (QueryException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $changes */
|
||||
public function record(
|
||||
string $action,
|
||||
string $targetType,
|
||||
?int $targetId,
|
||||
string $targetLabel,
|
||||
array $changes,
|
||||
int $actorId,
|
||||
): CourierSurchargeAuditLog {
|
||||
abort_unless($this->ready(), 503, '附加费操作日志数据表尚未完成迁移。');
|
||||
abort_unless(array_key_exists($action, self::ACTION_LABELS), 422, '附加费日志动作不合法。');
|
||||
abort_unless(array_key_exists($targetType, self::TARGET_LABELS), 422, '附加费日志对象不合法。');
|
||||
|
||||
return CourierSurchargeAuditLog::query()->create([
|
||||
'company_id' => company_id(),
|
||||
'actor_id' => $actorId ?: null,
|
||||
'action' => $action,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $targetId,
|
||||
'target_label' => mb_substr(trim($targetLabel), 0, 191),
|
||||
'changes' => $changes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int,array<string,mixed>>
|
||||
*/
|
||||
public function recent(int $limit = 50): Collection
|
||||
{
|
||||
if (! $this->ready()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
try {
|
||||
return CourierSurchargeAuditLog::query()
|
||||
->with('actor')
|
||||
->orderByDesc('id')
|
||||
->limit(max(1, min($limit, 200)))
|
||||
->get()
|
||||
->map(fn (CourierSurchargeAuditLog $log): array => [
|
||||
'id' => $log->id,
|
||||
'occurred_at' => optional($log->created_at)->format('Y-m-d H:i:s') ?: '',
|
||||
'actor_name' => $log->actor?->name ?: '系统',
|
||||
'action' => $log->action,
|
||||
'action_label' => self::ACTION_LABELS[$log->action] ?? $log->action,
|
||||
'target_type_label' => self::TARGET_LABELS[$log->target_type] ?? $log->target_type,
|
||||
'target_label' => $log->target_label,
|
||||
'changes_label' => $this->changesLabel($log),
|
||||
])
|
||||
->values();
|
||||
} catch (QueryException) {
|
||||
return collect();
|
||||
}
|
||||
}
|
||||
|
||||
private function changesLabel(CourierSurchargeAuditLog $log): string
|
||||
{
|
||||
$changes = (array) $log->changes;
|
||||
if ($log->action === CourierSurchargeAuditLog::ACTION_CREATED) {
|
||||
return '新增记录';
|
||||
}
|
||||
|
||||
if (in_array($log->action, [CourierSurchargeAuditLog::ACTION_ACTIVATED, CourierSurchargeAuditLog::ACTION_DEACTIVATED], true)) {
|
||||
$before = data_get($changes, 'before.status');
|
||||
$after = data_get($changes, 'after.status');
|
||||
return sprintf('状态:%s → %s', $before ?: '—', $after ?: '—');
|
||||
}
|
||||
|
||||
$before = (array) ($changes['before'] ?? []);
|
||||
$after = (array) ($changes['after'] ?? []);
|
||||
$parts = [];
|
||||
foreach (array_unique(array_merge(array_keys($before), array_keys($after))) as $field) {
|
||||
$old = $this->valueLabel($before[$field] ?? null);
|
||||
$new = $this->valueLabel($after[$field] ?? null);
|
||||
if ($old !== $new) {
|
||||
$parts[] = sprintf('%s:%s → %s', self::FIELD_LABELS[$field] ?? $field, $old, $new);
|
||||
}
|
||||
}
|
||||
|
||||
return $parts === [] ? '未改变字段' : implode(';', $parts);
|
||||
}
|
||||
|
||||
private function valueLabel(mixed $value): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return implode('、', array_map(static fn (mixed $item): string => (string) $item, $value));
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
|
||||
return $value === '' ? '—' : $value;
|
||||
}
|
||||
}
|
||||
@ -2,10 +2,12 @@
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\CourierSurchargeAuditLog;
|
||||
use App\Models\Business\CourierSurchargeQuote;
|
||||
use App\Models\Business\CourierSurchargeType;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
@ -28,7 +30,10 @@ class CourierSurchargeQuoteRepository
|
||||
|
||||
public const SCOPES = ['自有快递', '代发仓'];
|
||||
|
||||
public function __construct(private readonly CourierSurchargeTypeRepository $types)
|
||||
public function __construct(
|
||||
private readonly CourierSurchargeTypeRepository $types,
|
||||
private readonly CourierSurchargeAuditLogRepository $audits,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@ -154,27 +159,102 @@ class CourierSurchargeQuoteRepository
|
||||
/** @param array<string,mixed> $data */
|
||||
public function create(array $data, int $userId): CourierSurchargeQuote
|
||||
{
|
||||
return CourierSurchargeQuote::query()->create($this->attributes($data, $userId) + [
|
||||
'status' => CourierSurchargeQuote::STATUS_ACTIVE,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
return DB::transaction(function () use ($data, $userId): CourierSurchargeQuote {
|
||||
$quote = CourierSurchargeQuote::query()->create($this->attributes($data, $userId) + [
|
||||
'status' => CourierSurchargeQuote::STATUS_ACTIVE,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
$this->audits->record(
|
||||
CourierSurchargeAuditLog::ACTION_CREATED,
|
||||
CourierSurchargeAuditLog::TARGET_QUOTE,
|
||||
(int) $quote->id,
|
||||
$this->targetLabel($quote),
|
||||
['after' => $this->snapshot($quote)],
|
||||
$userId,
|
||||
);
|
||||
|
||||
return $quote;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function update(CourierSurchargeQuote $quote, array $data, int $userId): CourierSurchargeQuote
|
||||
{
|
||||
$quote->update($this->attributes($data, $userId));
|
||||
return DB::transaction(function () use ($quote, $data, $userId): CourierSurchargeQuote {
|
||||
$before = $this->snapshot($quote);
|
||||
$quote->update($this->attributes($data, $userId));
|
||||
$fresh = $quote->fresh();
|
||||
|
||||
return $quote->fresh();
|
||||
$this->audits->record(
|
||||
CourierSurchargeAuditLog::ACTION_UPDATED,
|
||||
CourierSurchargeAuditLog::TARGET_QUOTE,
|
||||
(int) $fresh->id,
|
||||
$this->targetLabel($fresh),
|
||||
['before' => $before, 'after' => $this->snapshot($fresh)],
|
||||
$userId,
|
||||
);
|
||||
|
||||
return $fresh;
|
||||
});
|
||||
}
|
||||
|
||||
public function setStatus(CourierSurchargeQuote $quote, string $status, int $userId): CourierSurchargeQuote
|
||||
{
|
||||
abort_unless(in_array($status, [CourierSurchargeQuote::STATUS_ACTIVE, CourierSurchargeQuote::STATUS_INACTIVE], true), 422, '报价规则状态不合法。');
|
||||
|
||||
$quote->forceFill(['status' => $status, 'updated_by' => $userId])->save();
|
||||
return DB::transaction(function () use ($quote, $status, $userId): CourierSurchargeQuote {
|
||||
$oldStatus = $quote->status;
|
||||
$quote->forceFill(['status' => $status, 'updated_by' => $userId])->save();
|
||||
$fresh = $quote->fresh();
|
||||
|
||||
return $quote->fresh();
|
||||
if ($oldStatus !== $status) {
|
||||
$this->audits->record(
|
||||
$status === CourierSurchargeQuote::STATUS_ACTIVE
|
||||
? CourierSurchargeAuditLog::ACTION_ACTIVATED
|
||||
: CourierSurchargeAuditLog::ACTION_DEACTIVATED,
|
||||
CourierSurchargeAuditLog::TARGET_QUOTE,
|
||||
(int) $fresh->id,
|
||||
$this->targetLabel($fresh),
|
||||
[
|
||||
'before' => ['status' => $this->statusLabel($oldStatus)],
|
||||
'after' => ['status' => $this->statusLabel($status)],
|
||||
],
|
||||
$userId,
|
||||
);
|
||||
}
|
||||
|
||||
return $fresh;
|
||||
});
|
||||
}
|
||||
|
||||
private function targetLabel(CourierSurchargeQuote $quote): string
|
||||
{
|
||||
$type = $quote->typeLabel();
|
||||
|
||||
return trim(sprintf('%s%s', $quote->courier, $type === '' ? '' : ' · ' . $type));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function snapshot(CourierSurchargeQuote $quote): array
|
||||
{
|
||||
return [
|
||||
'courier' => $quote->courier,
|
||||
'scope' => $quote->scope,
|
||||
'reconciler' => $quote->reconciler,
|
||||
'types' => array_values((array) $quote->types),
|
||||
'mode' => $quote->mode,
|
||||
'standard' => $quote->standard,
|
||||
'condition' => $quote->condition,
|
||||
'effective_at' => optional($quote->effective_at)->format('Y-m-d'),
|
||||
'status' => $this->statusLabel($quote->status),
|
||||
'remark' => $quote->remark,
|
||||
];
|
||||
}
|
||||
|
||||
private function statusLabel(?string $status): string
|
||||
{
|
||||
return $status === CourierSurchargeQuote::STATUS_ACTIVE ? '已启用' : '已停用';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
|
||||
@ -2,10 +2,12 @@
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\CourierSurchargeAuditLog;
|
||||
use App\Models\Business\CourierSurchargeQuote;
|
||||
use App\Models\Business\CourierSurchargeType;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@ -17,6 +19,10 @@ use Illuminate\Validation\ValidationException;
|
||||
*/
|
||||
class CourierSurchargeTypeRepository
|
||||
{
|
||||
public function __construct(private readonly CourierSurchargeAuditLogRepository $audits)
|
||||
{
|
||||
}
|
||||
|
||||
/** @var list<string> */
|
||||
public const DEFAULT_NAMES = [
|
||||
'退回费', '拦截费', '改地址', '异形件', '超长费',
|
||||
@ -89,14 +95,27 @@ class CourierSurchargeTypeRepository
|
||||
|
||||
$sortOrder = ((int) CourierSurchargeType::query()->max('sort_order')) + 1;
|
||||
|
||||
return CourierSurchargeType::query()->create([
|
||||
'company_id' => company_id(),
|
||||
'name' => $name,
|
||||
'status' => CourierSurchargeType::STATUS_ACTIVE,
|
||||
'sort_order' => $sortOrder,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
return DB::transaction(function () use ($name, $sortOrder, $userId): CourierSurchargeType {
|
||||
$type = CourierSurchargeType::query()->create([
|
||||
'company_id' => company_id(),
|
||||
'name' => $name,
|
||||
'status' => CourierSurchargeType::STATUS_ACTIVE,
|
||||
'sort_order' => $sortOrder,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
$this->audits->record(
|
||||
CourierSurchargeAuditLog::ACTION_CREATED,
|
||||
CourierSurchargeAuditLog::TARGET_TYPE,
|
||||
(int) $type->id,
|
||||
$type->name,
|
||||
['after' => ['name' => $type->name, 'status' => '已启用']],
|
||||
$userId,
|
||||
);
|
||||
|
||||
return $type;
|
||||
});
|
||||
} catch (QueryException $exception) {
|
||||
if ((int) $exception->getCode() === 23000) {
|
||||
throw ValidationException::withMessages(['name' => '当前公司已有同名附加费类型。']);
|
||||
@ -110,12 +129,37 @@ class CourierSurchargeTypeRepository
|
||||
{
|
||||
abort_unless(in_array($status, [CourierSurchargeType::STATUS_ACTIVE, CourierSurchargeType::STATUS_INACTIVE], true), 422, '附加费类型状态不合法。');
|
||||
|
||||
$type->forceFill([
|
||||
'status' => $status,
|
||||
'updated_by' => $userId,
|
||||
])->save();
|
||||
return DB::transaction(function () use ($type, $status, $userId): CourierSurchargeType {
|
||||
$oldStatus = $type->status;
|
||||
$type->forceFill([
|
||||
'status' => $status,
|
||||
'updated_by' => $userId,
|
||||
])->save();
|
||||
$fresh = $type->fresh();
|
||||
|
||||
return $type->fresh();
|
||||
if ($oldStatus !== $status) {
|
||||
$this->audits->record(
|
||||
$status === CourierSurchargeType::STATUS_ACTIVE
|
||||
? CourierSurchargeAuditLog::ACTION_ACTIVATED
|
||||
: CourierSurchargeAuditLog::ACTION_DEACTIVATED,
|
||||
CourierSurchargeAuditLog::TARGET_TYPE,
|
||||
(int) $fresh->id,
|
||||
$fresh->name,
|
||||
[
|
||||
'before' => ['status' => $this->statusLabel($oldStatus)],
|
||||
'after' => ['status' => $this->statusLabel($status)],
|
||||
],
|
||||
$userId,
|
||||
);
|
||||
}
|
||||
|
||||
return $fresh;
|
||||
});
|
||||
}
|
||||
|
||||
private function statusLabel(?string $status): string
|
||||
{
|
||||
return $status === CourierSurchargeType::STATUS_ACTIVE ? '已启用' : '已停用';
|
||||
}
|
||||
|
||||
private function ensureDefaults(int $companyId): void
|
||||
|
||||
@ -35,7 +35,7 @@ class TaobaoAlipayBillSpreadsheetImporter
|
||||
'counterparty_account', 'account_order_no', 'business_serial_no', 'goods_name',
|
||||
];
|
||||
|
||||
public function import(string $path, int $companyId): array
|
||||
public function import(string $path, int $companyId, bool $replace = false): array
|
||||
{
|
||||
if (! is_file($path) || ! is_readable($path)) {
|
||||
throw new InvalidArgumentException('导入文件不存在或不可读取。');
|
||||
@ -55,21 +55,27 @@ class TaobaoAlipayBillSpreadsheetImporter
|
||||
throw new InvalidArgumentException('表头与淘系支付宝账户模板不一致。');
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$skipped = 0;
|
||||
for ($rowNumber = 2; $rowNumber <= $sheet->getHighestDataRow(); $rowNumber++) {
|
||||
$values = $sheet->rangeToArray("A{$rowNumber}:Y{$rowNumber}", null, false, false)[0];
|
||||
if (collect($values)->every(fn ($value) => $value === null || trim((string) $value) === '')) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$rows[] = $this->normalizeRow(array_combine(self::FIELDS, $values), $rowNumber);
|
||||
}
|
||||
|
||||
$inserted = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
$replaced = 0;
|
||||
|
||||
DB::transaction(function () use ($sheet, $companyId, &$inserted, &$updated, &$skipped) {
|
||||
for ($rowNumber = 2; $rowNumber <= $sheet->getHighestDataRow(); $rowNumber++) {
|
||||
$values = $sheet->rangeToArray("A{$rowNumber}:Y{$rowNumber}", null, false, false)[0];
|
||||
DB::transaction(function () use ($rows, $companyId, $replace, &$inserted, &$updated, &$replaced) {
|
||||
if ($replace) {
|
||||
$replaced = DB::table('ec_alipay_bills')->where('company_id', $companyId)->delete();
|
||||
}
|
||||
|
||||
if (collect($values)->every(fn ($value) => $value === null || trim((string) $value) === '')) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = array_combine(self::FIELDS, $values);
|
||||
$data = $this->normalizeRow($data, $rowNumber);
|
||||
foreach ($rows as $data) {
|
||||
$record = AlipayBill::query()
|
||||
->where('company_id', $companyId)
|
||||
->where('record_code', $data['record_code'])
|
||||
@ -93,7 +99,7 @@ class TaobaoAlipayBillSpreadsheetImporter
|
||||
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
|
||||
return compact('inserted', 'updated', 'skipped');
|
||||
return compact('inserted', 'updated', 'skipped', 'replaced');
|
||||
}
|
||||
|
||||
private function normalizeRow(array $data, int $rowNumber): array
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\InventoryReportAuthoritativeImporter;
|
||||
use Throwable;
|
||||
|
||||
class ImportInventoryReportAuthoritativeMonth extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:import-inventory-report-month
|
||||
{path : 聚水潭按商品、按仓库、按月多维分析完整 XLSX 导出文件}';
|
||||
|
||||
protected $description = '导入进销存报表权威月明细,保留仓储方和子仓维度';
|
||||
|
||||
public function handle(InventoryReportAuthoritativeImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import((string) $this->argument('path'));
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('进销存权威月报导入失败:' . $exception->getMessage());
|
||||
$this->warn('已保留上一版有效数据。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'进销存权威月报导入完成:月份 %s,明细 %d 行。',
|
||||
$result['source_month'],
|
||||
$result['source_rows'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,8 @@ class ImportTaobaoAlipayBills extends Command
|
||||
{
|
||||
protected $signature = 'business:import-taobao-alipay-bills
|
||||
{file : 淘系支付宝账户 Excel 文件路径}
|
||||
{--company=1 : 数据所属公司 ID}';
|
||||
{--company=1 : 数据所属公司 ID}
|
||||
{--replace : 校验通过后替换该公司的全部淘系支付宝账单}';
|
||||
|
||||
protected $description = '从标准附件导入店铺收款账户账单的淘系支付宝账户数据';
|
||||
|
||||
@ -20,6 +21,7 @@ class ImportTaobaoAlipayBills extends Command
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('file'),
|
||||
max(1, (int) $this->option('company')),
|
||||
(bool) $this->option('replace'),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$this->components->error($e->getMessage());
|
||||
@ -28,7 +30,7 @@ class ImportTaobaoAlipayBills extends Command
|
||||
}
|
||||
|
||||
$this->components->info(
|
||||
"导入完成:新增 {$result['inserted']} 条,更新 {$result['updated']} 条,跳过 {$result['skipped']} 条。"
|
||||
"导入完成:新增 {$result['inserted']} 条,更新 {$result['updated']} 条,替换旧数据 {$result['replaced']} 条,跳过 {$result['skipped']} 条。"
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
|
||||
@ -10,10 +10,10 @@ use Throwable;
|
||||
class SyncThirdPartyWarehouseStorage extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-third-party-warehouse-storage
|
||||
{--full : 启动或继续三方仓流水与库存的全量校准}
|
||||
{--full : 启动或继续聚水潭全仓流水与库存的全量校准}
|
||||
{--max-batches= : 本轮最多批次数,0 表示直到完成}';
|
||||
|
||||
protected $description = '同步南昌樵舍三方仓按商品进销存数据到共享业务镜像';
|
||||
protected $description = '同步聚水潭全部仓库按商品进销存数据到共享业务镜像';
|
||||
|
||||
public function handle(ThirdPartyWarehouseStorageSynchronizer $synchronizer): int
|
||||
{
|
||||
@ -27,29 +27,29 @@ class SyncThirdPartyWarehouseStorage extends Command
|
||||
$this->option('max-batches') === null ? null : (int) $this->option('max-batches'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('三方仓仓储数据共享镜像同步失败。', [
|
||||
Log::error('聚水潭进销存数据共享镜像同步失败。', [
|
||||
'dataset' => ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY,
|
||||
'exception' => $exception::class,
|
||||
]);
|
||||
$this->error('三方仓仓储数据同步失败,页面继续读取上一版完整数据。');
|
||||
$this->error('聚水潭进销存数据同步失败,页面继续读取上一版完整数据。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($result['status'] === 'requires_full_sync') {
|
||||
$this->warn('三方仓仓储数据尚未完成首次全量同步,请显式使用 --full。');
|
||||
$this->warn('聚水潭进销存数据尚未完成首次全量同步,请显式使用 --full。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($result['status'] === 'already_running') {
|
||||
$this->line('三方仓仓储数据已有同步任务运行,本次不重复启动。');
|
||||
$this->line('聚水潭进销存数据已有同步任务运行,本次不重复启动。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'三方仓仓储数据%s:%s,本轮 %d 批;流水 %d 条、明细 %d 条、库存 %d 条。',
|
||||
'聚水潭进销存数据%s:%s,本轮 %d 批;流水 %d 条、明细 %d 条、库存 %d 条。',
|
||||
$result['mode'] === 'full' ? '全量校准' : '增量同步',
|
||||
$result['status'] === 'succeeded' ? '完成' : '进行中',
|
||||
$result['batches'],
|
||||
|
||||
@ -278,6 +278,7 @@ class CourierAgreementSignatures extends Controller
|
||||
'quote_version_id' => ['required_if:document_type,quote-confirmation', 'nullable', 'integer', 'min:1'],
|
||||
'signing_subject' => ['required', 'string', 'min:2', 'max:255'],
|
||||
'company_full_name' => ['required', 'string', 'min:2', 'max:255'],
|
||||
'signature_date' => ['required', 'date'],
|
||||
'signing_deadline' => $date,
|
||||
'esign_account' => ['required', 'string', 'max:255'],
|
||||
'secondary_esign_account' => ['required_if:document_type,entity-change,cooperation-contract,authorization', 'nullable', 'string', 'max:255'],
|
||||
@ -299,12 +300,10 @@ class CourierAgreementSignatures extends Controller
|
||||
'entity_change_date' => ['required_if:document_type,entity-change', 'date'],
|
||||
'changed_company_name' => ['required_if:document_type,entity-change', 'string', 'max:255'],
|
||||
'changed_company_start_date' => ['required_if:document_type,entity-change', 'date'],
|
||||
'entity_proof_date' => ['required_if:document_type,entity-change', 'date'],
|
||||
'contract_partner_name' => ['required_if:document_type,shared-contract', 'string', 'max:255'],
|
||||
'shared_courier_names' => ['nullable', 'array', 'min:1'],
|
||||
'shared_courier_names.*' => ['required', 'string', 'max:255'],
|
||||
'shared_courier_name' => ['nullable', 'string', 'max:255'],
|
||||
'shared_proof_date' => ['nullable', 'date'],
|
||||
'shared_note' => ['nullable', 'string', 'max:2000'],
|
||||
'cooperation_party_a_name' => ['required_if:document_type,cooperation-contract', 'string', 'max:255'],
|
||||
'cooperation_party_a_phone' => ['required_if:document_type,cooperation-contract', 'string', 'max:64'],
|
||||
@ -342,7 +341,7 @@ class CourierAgreementSignatures extends Controller
|
||||
->findOrFail((int) $data['quote_version_id']);
|
||||
$this->ensureQuoteVersionMatchesCourier($version, $data['courier_name']);
|
||||
|
||||
$signature = $this->quoteSignatureService->prepareVersion($version, user_id());
|
||||
$signature = $this->quoteSignatureService->prepareVersion($version, user_id(), $data['signature_date']);
|
||||
$account = trim((string) $data['esign_account']);
|
||||
$signature = $this->quoteSignatureService->send($signature, [
|
||||
'organization_name' => $version->carrier_name,
|
||||
|
||||
@ -2,18 +2,40 @@
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Business\CourierPayableSummaryFilter;
|
||||
use App\Business\CourierPayableSummaryStore;
|
||||
use App\Utilities\SidebarPermissions;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class CourierPayableSummaries extends Controller
|
||||
{
|
||||
public function __construct(private readonly CourierPayableSummaryStore $store)
|
||||
{
|
||||
private const IMPORT_TEMPLATES = [
|
||||
'freight-difference' => [
|
||||
'path' => 'templates/courier-payables/freight-difference-import-template.xlsx',
|
||||
'ascii_name' => 'freight_difference_import_template.xlsx',
|
||||
'download_name' => '运费差导入模板.xlsx',
|
||||
],
|
||||
'other-surcharge-summary' => [
|
||||
'path' => 'templates/courier-payables/other-surcharge-import-template.xlsx',
|
||||
'ascii_name' => 'other_surcharge_import_template.xlsx',
|
||||
'download_name' => '其他附加费导入模板.xlsx',
|
||||
],
|
||||
'return-fee' => [
|
||||
'path' => 'templates/courier-payables/return-fee-import-template.xlsx',
|
||||
'ascii_name' => 'return_fee_import_template.xlsx',
|
||||
'download_name' => '退件费导入模板.xlsx',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly CourierPayableSummaryStore $store,
|
||||
private readonly CourierPayableSummaryFilter $filter,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
}
|
||||
@ -45,7 +67,28 @@ class CourierPayableSummaries extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function export(string $summaryPage): StreamedResponse|RedirectResponse
|
||||
public function template(string $summaryPage): BinaryFileResponse
|
||||
{
|
||||
$this->authorizePage($summaryPage);
|
||||
$template = self::IMPORT_TEMPLATES[$summaryPage] ?? null;
|
||||
abort_unless(is_array($template), 404);
|
||||
|
||||
$path = resource_path($template['path']);
|
||||
abort_unless(is_file($path), 404);
|
||||
|
||||
$response = response()->download($path, $template['ascii_name'], [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
|
||||
return $response->setContentDisposition(
|
||||
'attachment',
|
||||
$template['download_name'],
|
||||
$template['ascii_name'],
|
||||
);
|
||||
}
|
||||
|
||||
public function export(Request $request, string $summaryPage): StreamedResponse|RedirectResponse
|
||||
{
|
||||
$this->authorizePage($summaryPage);
|
||||
$title = $this->store->title($summaryPage);
|
||||
@ -60,7 +103,12 @@ class CourierPayableSummaries extends Controller
|
||||
return $this->redirectToPage($summaryPage)->with('error', '当前没有可导出的汇总数据。');
|
||||
}
|
||||
|
||||
return response()->streamDownload(function () use ($snapshot): void {
|
||||
$filters = $this->filter->fromRequest($request);
|
||||
$headers = $snapshot['headers'];
|
||||
$rows = $this->filter->apply(collect($snapshot['rows']), $headers, $filters)->values();
|
||||
$dateColumn = $this->filter->dateColumnIndex($headers);
|
||||
|
||||
return response()->streamDownload(function () use ($headers, $rows, $dateColumn): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
|
||||
if ($csv === false) {
|
||||
@ -68,10 +116,14 @@ class CourierPayableSummaries extends Controller
|
||||
}
|
||||
|
||||
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
fputcsv($csv, array_map([$this, 'csvText'], $snapshot['headers']));
|
||||
fputcsv($csv, array_map([$this, 'csvText'], $headers));
|
||||
|
||||
foreach ($snapshot['rows'] as $row) {
|
||||
fputcsv($csv, array_map([$this, 'csvText'], $row));
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($csv, array_map(
|
||||
fn (mixed $value, int $column): string => $this->csvText($value, $column === $dateColumn),
|
||||
$row,
|
||||
array_keys($row),
|
||||
));
|
||||
}
|
||||
|
||||
fclose($csv);
|
||||
@ -101,10 +153,14 @@ class CourierPayableSummaries extends Controller
|
||||
abort_unless($pages->contains('slug', $summaryPage), 403, '当前账号没有该页面权限。');
|
||||
}
|
||||
|
||||
private function csvText(mixed $value): string
|
||||
private function csvText(mixed $value, bool $preserveYearMonth = false): string
|
||||
{
|
||||
$value = (string) $value;
|
||||
|
||||
if ($preserveYearMonth && preg_match('/^\d{4}-(?:0[1-9]|1[0-2])$/', $value) === 1) {
|
||||
return '="' . $value . '"';
|
||||
}
|
||||
|
||||
if (preg_match('/^[+-]?(?:(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|\.\d+)%?$/u', $value) === 1) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
@ -19,11 +19,14 @@ class CourierQuoteSignatures extends Controller
|
||||
{
|
||||
}
|
||||
|
||||
public function prepare(CourierQuoteVersion $quoteVersion): RedirectResponse
|
||||
public function prepare(Request $request, CourierQuoteVersion $quoteVersion): RedirectResponse
|
||||
{
|
||||
$this->ensureCanManage();
|
||||
abort_unless($quoteVersion->status === 'approved', 422, '只有已审核报价版本可以生成签署文件。');
|
||||
$signature = $this->service->prepareVersion($quoteVersion, user_id());
|
||||
$data = $request->validate([
|
||||
'signature_date' => ['required', 'date'],
|
||||
]);
|
||||
$signature = $this->service->prepareVersion($quoteVersion, user_id(), $data['signature_date']);
|
||||
|
||||
return redirect()->route('business.courier-quote-changes.show', $quoteVersion->source_change_id)
|
||||
->with('success', "{$quoteVersion->version_code} 的签署 PDF 已生成,文件校验值已固定。")
|
||||
@ -40,9 +43,15 @@ class CourierQuoteSignatures extends Controller
|
||||
'operator_name' => ['required', 'string', 'min:2', 'max:100'],
|
||||
'operator_account' => ['required', 'string', 'max:255'],
|
||||
'notice_type' => ['required', 'in:1,2'],
|
||||
'signature_date' => ['required', 'date'],
|
||||
'expires_at' => ['required', 'date', 'after:now', 'before_or_equal:' . now()->addDays(90)->toDateTimeString()],
|
||||
]);
|
||||
$this->validateAccountForNotice($data['operator_account'], $data['notice_type']);
|
||||
$quoteSignature = $this->service->prepareVersion(
|
||||
$quoteSignature->version,
|
||||
user_id(),
|
||||
$data['signature_date'],
|
||||
);
|
||||
$signature = $this->service->send($quoteSignature, $data, user_id());
|
||||
$environment = $signature->environment === 'sandbox' ? '沙箱' : '正式环境';
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Business\BusinessTableImporter;
|
||||
use App\Business\CourierPayableSummaryFilter;
|
||||
use App\Business\CourierPayableSummaryStore;
|
||||
use App\Business\FundForecastTemplate;
|
||||
use App\Business\TableRegistry;
|
||||
@ -16,6 +17,7 @@ use App\Models\Business\CourierQuoteVersion;
|
||||
use App\Models\Business\CourierSigningSubject;
|
||||
use App\Models\Business\InstallationDetail;
|
||||
use App\Models\Business\StoreReceivingAccount;
|
||||
use App\Business\CourierSurchargeAuditLogRepository;
|
||||
use App\Business\CourierSurchargeQuoteRepository;
|
||||
use App\Business\CourierSurchargeTypeRepository;
|
||||
use App\Models\StoreReceivables\StoreZhongcaiReconciliation;
|
||||
@ -95,6 +97,7 @@ class Pages extends Controller
|
||||
private readonly UpstreamQueryCacheFactory $upstreamCaches,
|
||||
private readonly CourierSurchargeQuoteRepository $courierSurchargeQuotes,
|
||||
private readonly CourierSurchargeTypeRepository $courierSurchargeTypes,
|
||||
private readonly CourierSurchargeAuditLogRepository $courierSurchargeAudits,
|
||||
private readonly JushuitanShopBalanceRepository $jushuitanShopBalances,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
@ -381,6 +384,10 @@ class Pages extends Controller
|
||||
$tabColumn = null;
|
||||
$upstreamError = null;
|
||||
|
||||
if (($section['slug'] ?? null) === 'product-inventory' && ($page['slug'] ?? null) === 'inventory-report') {
|
||||
[$rows, $upstreamError] = $this->inventoryReportRows();
|
||||
}
|
||||
|
||||
if ($page && $content === null) {
|
||||
$descriptor = TableRegistry::describe(
|
||||
$section['slug'],
|
||||
@ -389,7 +396,7 @@ class Pages extends Controller
|
||||
);
|
||||
|
||||
if ($descriptor) {
|
||||
if ($descriptor->readOnly) {
|
||||
if ($descriptor->readOnly) {
|
||||
try {
|
||||
$rows = app(UpstreamBusinessInformation::class)->paginate(
|
||||
$section['slug'],
|
||||
@ -428,7 +435,11 @@ class Pages extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->isStoreReceivingAccountPage($section['slug'], $page['slug'])) {
|
||||
if (($section['slug'] ?? null) === 'product-inventory'
|
||||
&& ($page['slug'] ?? null) === 'inventory-report') {
|
||||
// inventoryReportRows() 已从聚水潭共享镜像按全仓口径取数。
|
||||
// 这里必须保留该结果,不能再被目录表的旧演示数据覆盖。
|
||||
} elseif ($this->isStoreReceivingAccountPage($section['slug'], $page['slug'])) {
|
||||
try {
|
||||
app(\App\Business\StoreReceivingAccountSynchronizer::class)->sync((int) (company_id() ?: 1));
|
||||
} catch (Throwable $exception) {
|
||||
@ -481,6 +492,69 @@ class Pages extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{0:LengthAwarePaginator,1:?string} */
|
||||
private function inventoryReportRows(): array
|
||||
{
|
||||
$page = max(1, request()->integer('page', 1));
|
||||
$perPage = min(max((int) setting('default.list_limit', 25), 1), 100);
|
||||
$filters = $this->inventoryReportFilters(request());
|
||||
$error = null;
|
||||
|
||||
try {
|
||||
if (! $this->thirdPartyWarehouseStorage->available()) {
|
||||
$error = '进销存共享镜像尚未建立,暂时无法读取。';
|
||||
} elseif (! $this->thirdPartyWarehouseStorage->allWarehousesReady()) {
|
||||
$error = '进销存全仓库共享镜像正在执行首次同步,完成前不会展示不完整的单仓数据。';
|
||||
} else {
|
||||
$result = $this->thirdPartyWarehouseStorage->paginateMonthlyInventoryReport(
|
||||
$filters,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
|
||||
return [new LengthAwarePaginator(
|
||||
$result['data'],
|
||||
$result['total'],
|
||||
$perPage,
|
||||
$page,
|
||||
[
|
||||
'path' => request()->url(),
|
||||
'query' => request()->except('page'),
|
||||
],
|
||||
), null];
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$error = '进销存共享镜像暂时无法读取,请稍后重试。';
|
||||
}
|
||||
|
||||
return [new LengthAwarePaginator([], 0, $perPage, $page, [
|
||||
'path' => request()->url(),
|
||||
'query' => request()->except('page'),
|
||||
]), $error];
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private function inventoryReportFilters(Request $request): array
|
||||
{
|
||||
$month = trim((string) $request->query('month', now()->startOfMonth()->subMonth()->format('Y-m')));
|
||||
if (preg_match('/^(\d{4})-(\d{2})$/', $month, $matches) !== 1
|
||||
|| ! checkdate((int) $matches[2], 1, (int) $matches[1])) {
|
||||
$month = now()->startOfMonth()->subMonth()->format('Y-m');
|
||||
}
|
||||
$minimumMonth = substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7);
|
||||
if ($month < $minimumMonth) {
|
||||
$month = $minimumMonth;
|
||||
}
|
||||
|
||||
return [
|
||||
'month' => $month,
|
||||
'product_code' => mb_substr(trim((string) $request->query('product_code', '')), 0, 100),
|
||||
'warehouse_provider' => mb_substr(trim((string) $request->query('warehouse_provider', '')), 0, 191),
|
||||
'category' => mb_substr(trim((string) $request->query('category', '')), 0, 191),
|
||||
];
|
||||
}
|
||||
|
||||
private function isStoreReceivingAccountPage(string $section, string $page): bool
|
||||
{
|
||||
return $section === 'fund-management' && $page === 'store-receiving-accounts';
|
||||
@ -634,7 +708,7 @@ class Pages extends Controller
|
||||
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
|
||||
'online-daifa-price' => $this->contentForOnlineDaifaPrice(),
|
||||
'courier-surcharge' => $this->contentForCourierSurcharge(),
|
||||
'freight-difference', 'other-surcharge-summary' => $this->contentForCourierPayableSummary($page),
|
||||
'freight-difference', 'other-surcharge-summary', 'return-fee' => $this->contentForCourierPayableSummary($page),
|
||||
'withdrawal-management' => $this->contentForWithdrawalManagement(),
|
||||
'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(),
|
||||
default => null,
|
||||
@ -653,22 +727,33 @@ class Pages extends Controller
|
||||
$error = '汇总数据暂时无法读取,请重新导入后再试。';
|
||||
}
|
||||
|
||||
$headers = $snapshot['headers'] ?? [];
|
||||
$allRows = collect($snapshot['rows'] ?? []);
|
||||
$currentPage = max((int) request()->query('page', 1), 1);
|
||||
$filter = app(CourierPayableSummaryFilter::class);
|
||||
$filters = $filter->fromRequest(request());
|
||||
$filteredRows = $filter->apply($allRows, $headers, $filters);
|
||||
$perPage = 50;
|
||||
$lastPage = max((int) ceil($filteredRows->count() / $perPage), 1);
|
||||
$currentPage = min(max((int) request()->query('page', 1), 1), $lastPage);
|
||||
|
||||
return [
|
||||
'view' => 'business.pages.courier-payables.summary-table',
|
||||
'courierSummaryPage' => $page,
|
||||
'courierSummaryTitle' => $store->title($page),
|
||||
'courierSummaryHeaders' => $snapshot['headers'] ?? [],
|
||||
'courierSummaryHeaders' => $headers,
|
||||
'courierSummaryRows' => new LengthAwarePaginator(
|
||||
$allRows->forPage($currentPage, $perPage)->values(),
|
||||
$allRows->count(),
|
||||
$filteredRows->forPage($currentPage, $perPage)->values(),
|
||||
$filteredRows->count(),
|
||||
$perPage,
|
||||
$currentPage,
|
||||
['path' => request()->url(), 'query' => request()->except('page')],
|
||||
['path' => request()->url(), 'query' => array_filter(
|
||||
$filters,
|
||||
static fn (string $value): bool => $value !== '',
|
||||
)],
|
||||
),
|
||||
'courierSummaryFilters' => $filters,
|
||||
'courierSummaryHasFilters' => $filter->hasFilters($filters),
|
||||
'courierSummarySourceTotal' => $allRows->count(),
|
||||
'courierSummarySourceName' => $snapshot['source_name'] ?? null,
|
||||
'courierSummaryImportedAt' => $snapshot['imported_at'] ?? null,
|
||||
'courierSummaryError' => $error,
|
||||
@ -800,6 +885,7 @@ class Pages extends Controller
|
||||
'status' => $clean(request('status', ''), 20),
|
||||
];
|
||||
$data = $this->courierSurchargeQuotes->pageData($filters);
|
||||
$auditReady = $this->courierSurchargeAudits->ready();
|
||||
|
||||
return [
|
||||
'view' => 'business.pages.courier-payables.courier-surcharge',
|
||||
@ -807,8 +893,11 @@ class Pages extends Controller
|
||||
'courierSurchargeFilters' => $filters,
|
||||
'courierSurchargeSummary' => $data['summary'],
|
||||
'courierSurchargeOptions' => $data['options'],
|
||||
'courierSurchargeReady' => $data['ready'],
|
||||
'courierSurchargeReady' => $data['ready'] && $auditReady,
|
||||
'courierSurchargeDataReady' => $data['ready'],
|
||||
'courierSurchargeAuditReady' => $auditReady,
|
||||
'courierSurchargeTypeUsage' => $this->courierSurchargeTypes->usageCounts($data['options']['type_records'] ?? collect()),
|
||||
'courierSurchargeAuditLogs' => $this->courierSurchargeAudits->recent(),
|
||||
];
|
||||
}
|
||||
|
||||
@ -986,6 +1075,45 @@ class Pages extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function exportInventoryReport(Request $request): StreamedResponse
|
||||
{
|
||||
abort_unless(
|
||||
$this->thirdPartyWarehouseStorage->available()
|
||||
&& $this->thirdPartyWarehouseStorage->allWarehousesReady(),
|
||||
503,
|
||||
);
|
||||
|
||||
$filters = $this->inventoryReportFilters($request);
|
||||
$productInventory = collect(config('business_navigation'))->firstWhere('slug', 'product-inventory');
|
||||
$inventoryPage = collect($productInventory['pages'] ?? [])->firstWhere('slug', 'inventory-report');
|
||||
$columns = $inventoryPage['directory']['fields'] ?? [];
|
||||
|
||||
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->thirdPartyWarehouseStorage->exportMonthlyInventoryReportRows($filters) as $row) {
|
||||
fputcsv($csv, array_map(function (array $column) use ($row): mixed {
|
||||
$value = data_get($row, $column['name']);
|
||||
|
||||
return $column['name'] === 'product_code'
|
||||
? $this->csvIdentifier($value)
|
||||
: (($column['kind'] ?? null) === 'integer'
|
||||
? ($value === null || $value === '' ? '' : $value)
|
||||
: $this->csvText($value));
|
||||
}, $columns));
|
||||
}
|
||||
fclose($csv);
|
||||
}, '进销存报表_' . str_replace('-', '', $filters['month']) . '_' . now()->format('Ymd_His') . '.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private function thirdPartyWarehouseStorageFilters(Request $request): array
|
||||
{
|
||||
|
||||
44
app/Models/Business/CourierSurchargeAuditLog.php
Normal file
44
app/Models/Business/CourierSurchargeAuditLog.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Business;
|
||||
|
||||
use App\Abstracts\Model;
|
||||
|
||||
class CourierSurchargeAuditLog extends Model
|
||||
{
|
||||
public const ACTION_CREATED = 'created';
|
||||
|
||||
public const ACTION_UPDATED = 'updated';
|
||||
|
||||
public const ACTION_ACTIVATED = 'activated';
|
||||
|
||||
public const ACTION_DEACTIVATED = 'deactivated';
|
||||
|
||||
public const TARGET_QUOTE = 'quote';
|
||||
|
||||
public const TARGET_TYPE = 'type';
|
||||
|
||||
protected $table = 'business_courier_surcharge_audit_logs';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'actor_id',
|
||||
'action',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'target_label',
|
||||
'changes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'company_id' => 'integer',
|
||||
'actor_id' => 'integer',
|
||||
'target_id' => 'integer',
|
||||
'changes' => 'array',
|
||||
];
|
||||
|
||||
public function actor()
|
||||
{
|
||||
return $this->belongsTo(user_model_class(), 'actor_id');
|
||||
}
|
||||
}
|
||||
@ -3,17 +3,26 @@
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Business\CourierAgreementSignature;
|
||||
use Illuminate\Support\Carbon;
|
||||
use RuntimeException;
|
||||
|
||||
final class CourierAgreementPdf
|
||||
{
|
||||
public function render(CourierAgreementSignature $signature): string
|
||||
{
|
||||
$payload = $signature->payload ?? [];
|
||||
$signatureDate = (string) ($payload['signature_date'] ?? $payload['entity_proof_date'] ?? $payload['shared_proof_date'] ?? '');
|
||||
try {
|
||||
$signatureDate = $signatureDate !== '' ? Carbon::parse($signatureDate)->format('Y年n月j日') : '待填写';
|
||||
} catch (\Throwable) {
|
||||
$signatureDate = '待填写';
|
||||
}
|
||||
$html = view('business.pdf.courier-agreement', [
|
||||
'signature' => $signature,
|
||||
'type' => $signature->document_type,
|
||||
'title' => $this->title($signature),
|
||||
'payload' => $signature->payload ?? [],
|
||||
'payload' => $payload,
|
||||
'signatureDate' => $signatureDate,
|
||||
'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')),
|
||||
])->render();
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Business\CourierQuoteVersion;
|
||||
use Illuminate\Support\Carbon;
|
||||
use RuntimeException;
|
||||
|
||||
final class CourierQuotePdf
|
||||
@ -11,9 +12,10 @@ final class CourierQuotePdf
|
||||
{
|
||||
}
|
||||
|
||||
public function render(CourierQuoteVersion $version): string
|
||||
public function render(CourierQuoteVersion $version, ?string $signatureDate = null): string
|
||||
{
|
||||
$data = $this->confirmation->data($version);
|
||||
$signatureDate = Carbon::parse($signatureDate ?: now())->format('Y年n月j日');
|
||||
$html = view('business.pdf.courier-quote', [
|
||||
'version' => $version,
|
||||
'change' => $version->change,
|
||||
@ -23,6 +25,7 @@ final class CourierQuotePdf
|
||||
'expiryDate' => $data['expiry_date'],
|
||||
'faceFee' => $data['face_fee'],
|
||||
'bubbleCoefficient' => $data['bubble_coefficient'],
|
||||
'signatureDate' => $signatureDate,
|
||||
'generatedAt' => now(),
|
||||
'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')),
|
||||
])->render();
|
||||
|
||||
@ -18,11 +18,11 @@ final class CourierQuoteSignatureService
|
||||
{
|
||||
}
|
||||
|
||||
public function prepareVersion(CourierQuoteVersion $version, int $userId): CourierQuoteSignature
|
||||
public function prepareVersion(CourierQuoteVersion $version, int $userId, ?string $signatureDate = null): CourierQuoteSignature
|
||||
{
|
||||
$existing = $version->signature;
|
||||
|
||||
if ($existing && $this->sourcePdfIsIntact($existing)) {
|
||||
if ($signatureDate === null && $existing && $this->sourcePdfIsIntact($existing)) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ final class CourierQuoteSignatureService
|
||||
]);
|
||||
}
|
||||
|
||||
$bytes = $this->pdf->render($version);
|
||||
$bytes = $this->pdf->render($version, $signatureDate);
|
||||
$path = $this->path($version, 'source.pdf');
|
||||
$sha256 = hash('sha256', $bytes);
|
||||
|
||||
|
||||
@ -60,6 +60,7 @@ class Title extends Component
|
||||
$context = Str::lower($routeName . ' ' . request()->path());
|
||||
|
||||
$icons = [
|
||||
'financial-risk-control' => 'security',
|
||||
'store-receiving-bills' => 'account_balance_wallet',
|
||||
'marketing-account-bills' => 'account_balance_wallet',
|
||||
'supplier-payables' => 'local_shipping',
|
||||
|
||||
@ -302,6 +302,39 @@ $navigation = [
|
||||
'pages' => [
|
||||
['slug' => 'normal-products', 'title' => '普通商品资料'],
|
||||
['slug' => 'online-daifa-price', 'title' => '线上代发价格'],
|
||||
[
|
||||
'slug' => 'inventory-report',
|
||||
'title' => '进销存报表',
|
||||
'directory' => [
|
||||
'icon' => 'inventory',
|
||||
'status' => '共享镜像',
|
||||
'description' => '复用“应付款项 → 快递应付 → 三方仓进销存”的已确认流水、库存锚点和业务类型口径,按所有仓库、商品和月份汇总。',
|
||||
'empty' => '进销存共享镜像尚未完成首次同步。',
|
||||
'fields' => [
|
||||
['name' => 'owner', 'label' => '货主'],
|
||||
['name' => 'month', 'label' => '月份', 'kind' => 'date'],
|
||||
['name' => 'warehouse_provider', 'label' => '仓储方'],
|
||||
['name' => 'warehouse', 'label' => '仓库'],
|
||||
['name' => 'product_code', 'label' => '商品编码'],
|
||||
['name' => 'category', 'label' => '分类'],
|
||||
['name' => 'opening_quantity', 'label' => '期初数量', 'kind' => 'integer'],
|
||||
['name' => 'closing_quantity', 'label' => '期末数量', 'kind' => 'integer'],
|
||||
['name' => 'processing_in', 'label' => '加工进仓', 'kind' => 'integer'],
|
||||
['name' => 'processing_out', 'label' => '加工出仓', 'kind' => 'integer'],
|
||||
['name' => 'purchase_in', 'label' => '采购进仓', 'kind' => 'integer'],
|
||||
['name' => 'purchase_return', 'label' => '采购退货', 'kind' => 'integer'],
|
||||
['name' => 'other_in', 'label' => '其它进仓', 'kind' => 'integer'],
|
||||
['name' => 'other_return', 'label' => '其它退货', 'kind' => 'integer'],
|
||||
['name' => 'sales_out', 'label' => '销售出仓', 'kind' => 'integer'],
|
||||
['name' => 'sales_return', 'label' => '销售退货', 'kind' => 'integer'],
|
||||
['name' => 'stocktake', 'label' => '盘点', 'kind' => 'integer'],
|
||||
['name' => 'transfer_out', 'label' => '调拨出', 'kind' => 'integer'],
|
||||
['name' => 'transfer_in', 'label' => '调拨入', 'kind' => 'integer'],
|
||||
['name' => 'opening', 'label' => '期初', 'kind' => 'integer'],
|
||||
['name' => 'other_out', 'label' => '其它出库', 'kind' => 'integer'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -508,6 +541,7 @@ $navigation = [
|
||||
['slug' => 'courier-payables-report', 'title' => '快递应付表'],
|
||||
['slug' => 'freight-difference', 'title' => '运费差'],
|
||||
['slug' => 'other-surcharge-summary', 'title' => '其他附加费汇总'],
|
||||
['slug' => 'return-fee', 'title' => '退件费'],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -592,6 +626,13 @@ $navigation = [
|
||||
['slug' => 'financial-analysis', 'title' => '财务经营分析表'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'financial-risk-control',
|
||||
'title' => '财务经营风险管控',
|
||||
'icon' => 'security',
|
||||
// Platform views are horizontal tabs inside the page, not sidebar entries.
|
||||
'pages' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'financial-sw',
|
||||
'title' => '财务SW报表',
|
||||
|
||||
@ -87,8 +87,8 @@ return [
|
||||
'overlap_rows' => (int) env('STORE_REPORT_MIRROR_OVERLAP_ROWS', 50000),
|
||||
],
|
||||
|
||||
// 聚水潭进销存报表:固定只读“三方-南昌樵舍仓”,页面只读共享镜像。
|
||||
// 头表按 Id 区间小批读取并固定仓库,避免对上游流水表做无界扫描。
|
||||
// 聚水潭进销存共享流水镜像:头表按 Id 区间小批读取全部仓库。
|
||||
// 三方仓页面在 Repository 层继续固定南昌樵舍仓,进销存报表按全仓库汇总。
|
||||
'third_party_warehouse_storage' => [
|
||||
'dataset_key' => 'jiansun.third-party-warehouse-storage',
|
||||
'source_key' => 'jiansun',
|
||||
@ -222,6 +222,7 @@ return [
|
||||
['sku_type', 'autoid'],
|
||||
['sku_type', 'i_id', 'autoid'],
|
||||
['sku_type', 'sku_id', 'autoid'],
|
||||
['sku_id', 'autoid'],
|
||||
['sku_type', 'enabled', 'autoid'],
|
||||
['sku_type', 'stock_disabled', 'autoid'],
|
||||
],
|
||||
|
||||
@ -18,6 +18,7 @@ return new class extends Migration
|
||||
if (! $schema->hasTable('third_party_warehouse_sync_states')) {
|
||||
$schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key', 100)->primary();
|
||||
$table->unsignedTinyInteger('scope_version')->default(1);
|
||||
$table->string('status', 32)->default('pending');
|
||||
$table->string('run_mode', 24)->nullable();
|
||||
$table->string('sync_phase', 24)->nullable();
|
||||
@ -78,6 +79,7 @@ return new class extends Migration
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('io_id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->unsignedBigInteger('wh_id')->default(0);
|
||||
$table->string('status', 32)->nullable();
|
||||
$table->string('io_type', 64)->nullable();
|
||||
$table->dateTime('occurred_at', 3)->nullable();
|
||||
@ -90,6 +92,10 @@ return new class extends Migration
|
||||
['generation', 'status', 'occurred_at', 'io_type'],
|
||||
'tpw_headers_report_index',
|
||||
);
|
||||
$table->index(
|
||||
['generation', 'wms_co_id', 'occurred_at', 'io_type'],
|
||||
'tpw_headers_warehouse_report_index',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
<?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
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
|
||||
if ($schema->hasTable('third_party_warehouse_inout_headers')
|
||||
&& ! $schema->hasColumn('third_party_warehouse_inout_headers', 'wh_id')) {
|
||||
$schema->table('third_party_warehouse_inout_headers', function (Blueprint $table): void {
|
||||
$table->unsignedBigInteger('wh_id')->default(0)->after('wms_co_id');
|
||||
$table->index(
|
||||
['generation', 'wms_co_id', 'occurred_at', 'io_type'],
|
||||
'tpw_headers_warehouse_report_index',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if ($schema->hasTable('third_party_warehouse_inout_headers')
|
||||
&& $schema->hasColumn('third_party_warehouse_inout_headers', 'wh_id')) {
|
||||
$schema->table('third_party_warehouse_inout_headers', function (Blueprint $table): void {
|
||||
$table->dropIndex('tpw_headers_warehouse_report_index');
|
||||
$table->dropColumn('wh_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
<?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
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if ($schema->hasTable('third_party_warehouse_sync_states')
|
||||
&& ! $schema->hasColumn('third_party_warehouse_sync_states', 'scope_version')) {
|
||||
$schema->table('third_party_warehouse_sync_states', function (Blueprint $table): void {
|
||||
$table->unsignedTinyInteger('scope_version')->default(1)->after('dataset_key');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if ($schema->hasTable('third_party_warehouse_sync_states')
|
||||
&& $schema->hasColumn('third_party_warehouse_sync_states', 'scope_version')) {
|
||||
$schema->table('third_party_warehouse_sync_states', function (Blueprint $table): void {
|
||||
$table->dropColumn('scope_version');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
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
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if ($schema->hasTable('third_party_warehouse_inventory_subwarehouse_snapshots')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->create('third_party_warehouse_inventory_subwarehouse_snapshots', function (Blueprint $table): void {
|
||||
$table->bigIncrements('_mirror_id');
|
||||
$table->uuid('generation');
|
||||
$table->string('source_key', 64)->default('jiansun');
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->unsignedSmallInteger('wh_id');
|
||||
$table->string('sku_code', 191);
|
||||
$table->decimal('qty', 20, 4)->default(0);
|
||||
$table->dateTime('source_modified_at', 3)->nullable();
|
||||
$table->dateTime('captured_at', 6);
|
||||
$table->string('row_hash', 64);
|
||||
$table->dateTime('synced_at', 6);
|
||||
$table->unique(['generation', 'id', 'wh_id'], 'tpw_subinventory_generation_id_wh_unique');
|
||||
$table->index(
|
||||
['generation', 'wms_co_id', 'wh_id', 'sku_code'],
|
||||
'tpw_subinventory_report_index',
|
||||
);
|
||||
$table->index(['generation', 'sku_code'], 'tpw_subinventory_sku_index');
|
||||
});
|
||||
|
||||
$connection = DB::connection($this->connection());
|
||||
$connection->table('third_party_warehouse_inventory_subwarehouse_snapshots')->insertUsing(
|
||||
[
|
||||
'generation', 'source_key', 'id', 'wms_co_id', 'wh_id', 'sku_code', 'qty',
|
||||
'source_modified_at', 'captured_at', 'row_hash', 'synced_at',
|
||||
],
|
||||
$connection->table('third_party_warehouse_inventory_snapshots')->select([
|
||||
'generation', 'source_key', 'id', 'wms_co_id', DB::raw('1'), 'sku_code', 'qty',
|
||||
'source_modified_at', 'captured_at', 'row_hash', 'synced_at',
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::connection($this->connection())
|
||||
->dropIfExists('third_party_warehouse_inventory_subwarehouse_snapshots');
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('business_courier_surcharge_audit_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('business_courier_surcharge_audit_logs', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('company_id');
|
||||
$table->unsignedInteger('actor_id')->nullable();
|
||||
$table->string('action', 32);
|
||||
$table->string('target_type', 32);
|
||||
$table->unsignedInteger('target_id')->nullable();
|
||||
$table->string('target_label', 191);
|
||||
$table->text('changes')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['company_id', 'created_at'], 'courier_surcharge_audit_company_created_index');
|
||||
$table->index(['company_id', 'target_type', 'target_id'], 'courier_surcharge_audit_target_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('business_courier_surcharge_audit_logs');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
<?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
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
|
||||
if (! $schema->hasTable('inventory_report_authoritative_states')) {
|
||||
$schema->create('inventory_report_authoritative_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key', 120)->primary();
|
||||
$table->string('status', 24)->default('pending');
|
||||
$table->uuid('active_generation')->nullable();
|
||||
$table->uuid('pending_generation')->nullable();
|
||||
$table->string('source_filename', 255)->nullable();
|
||||
$table->char('source_sha256', 64)->nullable();
|
||||
$table->date('source_month')->nullable();
|
||||
$table->unsignedBigInteger('source_row_count')->default(0);
|
||||
$table->dateTime('last_attempted_at')->nullable();
|
||||
$table->dateTime('last_succeeded_at')->nullable();
|
||||
$table->string('last_error_code', 120)->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('inventory_report_authoritative_rows')) {
|
||||
$schema->create('inventory_report_authoritative_rows', function (Blueprint $table): void {
|
||||
$table->uuid('generation');
|
||||
$table->char('row_key', 64);
|
||||
$table->date('report_month');
|
||||
$table->string('owner', 191);
|
||||
$table->string('warehouse_provider', 191);
|
||||
$table->string('warehouse', 191);
|
||||
$table->string('product_code', 191);
|
||||
$table->string('category', 191)->nullable();
|
||||
$table->decimal('opening_quantity', 20, 4);
|
||||
$table->decimal('closing_quantity', 20, 4);
|
||||
foreach ([
|
||||
'processing_in', 'processing_out', 'purchase_in', 'purchase_return',
|
||||
'other_in', 'other_return', 'sales_out', 'sales_return', 'stocktake',
|
||||
'transfer_out', 'transfer_in', 'opening', 'other_out',
|
||||
] as $column) {
|
||||
$table->decimal($column, 20, 4)->default(0);
|
||||
}
|
||||
$table->dateTime('imported_at');
|
||||
|
||||
$table->primary(['generation', 'row_key'], 'inventory_report_authoritative_rows_primary');
|
||||
$table->index(
|
||||
['generation', 'report_month', 'product_code'],
|
||||
'inventory_report_authoritative_product_index',
|
||||
);
|
||||
$table->index(
|
||||
['generation', 'report_month'],
|
||||
'inventory_report_authoritative_month_index',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
$schema->dropIfExists('inventory_report_authoritative_rows');
|
||||
$schema->dropIfExists('inventory_report_authoritative_states');
|
||||
}
|
||||
};
|
||||
@ -12,7 +12,7 @@
|
||||
| --- | --- |
|
||||
| 已完成 | `operations_replica/rpt.Bulk_Price_Limit` 已进入类型化表 `product_price_mirror_rows`,商品价格页面已读取镜像并支持完整分页。 |
|
||||
| 已完成 | `operations_replica/rpt.jst_sku` 已进入 `ops_jst_sku`,普通商品资料页面固定读取 `sku_type = normal` 的共享镜像,并支持稳定分页和业务筛选。 |
|
||||
| 本次接入 | 聚水潭进销存“商品 + 按天”数据固定读取 `jiansun/dbo.jst_inoutWater + dbo.jst_inoutWaterItem + dbo.jst_inventoryInfo` 中仓库 `三方-南昌樵舍仓`(`wms_co_id = 15045364`),进入专用流水、明细、库存快照及同步状态镜像。页面位于“快递应付 / 三方仓进销存”,仅展示 2026-08-01 起的数据;按月或单日选择后均输出“日期 + 商品”明细,并支持分页、筛选、数量汇总和导出。 |
|
||||
| 本次接入 | 聚水潭进销存共享镜像读取 `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一键代发”标签的最近采购单价,并与原商品采购价分别标注来源。 |
|
||||
| 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 |
|
||||
@ -64,7 +64,7 @@
|
||||
| 营销账户 | `dbo.tmall_spread_bills`、`dbo.pdd_spread_bills`、`dbo.alibaba_bill`、`dbo.jd_spreadBills`、`dbo.jd_costBills` |
|
||||
| 供应商应付 | `dbo.finance_purchaseReturn`、`dbo.finance_invoiceDeduction`、`dbo.fys_supplierPaymentSummary`、`dbo.finance_advanceLoans`、`dbo.pm_unOrderDetails`、`dbo.finance_sampleDeduction`、`dbo.fys_express_courier_settlement` |
|
||||
| 快递运费对账明细 | `dbo.jst_order_express_details`、`dbo.Jst_SendData_View_extension`、`dbo.jst_salesOutboundInfo` |
|
||||
| 三方仓进销存 | `dbo.jst_inoutWater`、`dbo.jst_inoutWaterItem`、`dbo.jst_inventoryInfo`(仅 `wms_co_id = 15045364`、`io_date >= 2026-08-01`;专用稳定游标与完整代次镜像) |
|
||||
| 聚水潭进销存 | `dbo.jst_inoutWater`、`dbo.jst_inoutWaterItem`、`dbo.jst_inventoryInfo`(全部仓库、`io_date >= 2026-08-01`;专用稳定游标与完整代次镜像。三方仓页面仍固定 `wms_co_id = 15045364`,商品价格管理的进销存报表读取全仓库) |
|
||||
| 安装明细 | `dbo.fys_Ticketing_install_view`、`dbo.fys_Ticketing_install_item` |
|
||||
| 板材 | `dbo.finance_boardDeduction`、`dbo.pm_plainPlateData`、`dbo.pm_boardPurchasePrice`、`dbo.pm_boardPriceChangeImport`、`dbo.pm_boardApplyDetail` |
|
||||
| 封边条 | `dbo.pm_edgeBandData`、`dbo.pm_edgeBandApplyDetail`、`dbo.pm_edgeBandProfitInfo`、`dbo.finance_edgeBandDeductio`、`dbo.pm_averageUnitPrice` |
|
||||
@ -98,7 +98,7 @@
|
||||
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` 保证金不得混入。
|
||||
13. 三方仓进销存首次全量按头表 `Id` 区间小批断点续传,头表只保存 `io_date >= 2026-08-01` 的目标仓数据,不激活半份数据;日常增量以头表 `(modified, Id)` 作稳定游标并保留 48 小时时间重叠,由命中头单驱动整单明细替换。全量和增量都先写待发布代次,库存抓取完成后再把流水追平到库存抓取之后的固定源时间边界,三部分成功后才一次切换;运行中或失败时页面始终读取上一成功代次。增量扫描不先限定仓库或业务日期,确保单据移出目标仓或日期改到起始日前时可从镜像撤回,重载当前版本时再固定仓库和起始日期。页面只统计 `Confirmed` 的 12 类进销存流水,数量按上游 `qty * qty_type` 保留符号。库存同时保存上游 `source_modified_at` 和实际 `captured_at`,历史余额以每个 SKU 的抓取时间为回推锚点;页面始终按“日期 + 商品”输出,日期区间固定为 `[当天 00:00, 次日 00:00)`。每天 `00:00` 执行增量同步,失败时继续展示上一成功代次。
|
||||
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` 的工作平台价格记录混为同一来源。
|
||||
|
||||
工作平台采购价格首次全量需在备份和负责人授权后单独执行:
|
||||
|
||||
@ -16,17 +16,17 @@ class AlipayBills extends Controller
|
||||
'store_name' => '原始店铺',
|
||||
'custom_remark' => '自定义备注',
|
||||
'bill_item' => '账单项目',
|
||||
'alipay_account' => '平台渠道',
|
||||
'alipay_account' => '支付宝账号',
|
||||
'account_order_no' => '账务订单号',
|
||||
'business_serial_no' => '业务流水号',
|
||||
'merchant_order_no' => '商户订单号',
|
||||
'business_base_order_no' => '业务基础订单号',
|
||||
'goods_name' => '商品编码',
|
||||
'goods_name' => '商品名称',
|
||||
'counterparty_account' => '对方账号',
|
||||
'occurred_at' => '发生时间',
|
||||
'income' => '收入',
|
||||
'expense' => '支出',
|
||||
'balance' => '收支净额',
|
||||
'balance' => '余额',
|
||||
'business_type' => '业务类型',
|
||||
'remark' => '备注',
|
||||
'online_order_no' => '线上单号',
|
||||
@ -52,7 +52,7 @@ class AlipayBills extends Controller
|
||||
$dir = $request->get('dir') === 'asc' ? 'asc' : 'desc';
|
||||
$filters = $request->validate([
|
||||
'store_name' => ['nullable', 'string', 'max:255'],
|
||||
'alipay_account' => ['nullable', 'string', 'max:128'],
|
||||
'source_channel' => ['nullable', 'string', 'max:128'],
|
||||
'bill_item' => ['nullable', 'string', 'max:255'],
|
||||
'business_type' => ['nullable', 'string', 'max:255'],
|
||||
'start' => ['nullable', 'date_format:Y-m-d'],
|
||||
@ -90,7 +90,7 @@ class AlipayBills extends Controller
|
||||
$this->authorizeStoreReceivingBills();
|
||||
$filters = $request->validate([
|
||||
'store_name' => ['nullable', 'string', 'max:255'],
|
||||
'alipay_account' => ['nullable', 'string', 'max:128'],
|
||||
'source_channel' => ['nullable', 'string', 'max:128'],
|
||||
'bill_item' => ['nullable', 'string', 'max:255'],
|
||||
'business_type' => ['nullable', 'string', 'max:255'],
|
||||
'start' => ['nullable', 'date_format:Y-m-d'],
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
@ -17,6 +18,7 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
|
||||
public const DATASET = 'operations_replica.rpt.jst_originalBillInfo';
|
||||
public const SHOP_DATASET = 'operations_replica.rpt.jst_shopInfo';
|
||||
public const SKU_DATASET = 'operations_replica.rpt.jst_sku';
|
||||
|
||||
public function __construct(
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
@ -58,7 +60,7 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
$perPage = min(max($perPage, 1), 100);
|
||||
$direction = $direction === 'asc' ? 'asc' : 'desc';
|
||||
$sortColumn = [
|
||||
'record_code' => in_array($mapping, ['pinduoduo', 'dewu'], true) ? 'b.auto_id' : 'b.Id',
|
||||
'record_code' => in_array($mapping, ['pinduoduo', 'dewu'], true) ? 'b.auto_id' : ($mapping === 'taobao' ? 'b.p_auto_id' : 'b.Id'),
|
||||
'occurred_at' => 'b.fee_time',
|
||||
'income' => 'b.amount',
|
||||
'expense' => 'b.amount',
|
||||
@ -88,8 +90,10 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
$query, $sortColumn, $direction, $page, $perPage, $filters, $channel, $targetTable,
|
||||
);
|
||||
$shopNames = $this->shopNames($billRows);
|
||||
$productNames = $mapping === 'taobao' ? $this->productNames($billRows) : [];
|
||||
$importedRows = $mapping === 'taobao' ? $this->importedRows($billRows) : [];
|
||||
$rows = collect($billRows)
|
||||
->map(fn (object $row): array => $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping))
|
||||
->map(fn (object $row): array => $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping, $productNames, $importedRows))
|
||||
->all();
|
||||
$income = $summary['income'];
|
||||
$expense = $summary['expense'];
|
||||
@ -146,7 +150,7 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
$shopNames = $this->allShopNames();
|
||||
$direction = $direction === 'desc' ? 'desc' : 'asc';
|
||||
$sortColumn = [
|
||||
'record_code' => in_array($mapping, ['pinduoduo', 'dewu'], true) ? 'b.auto_id' : 'b.Id',
|
||||
'record_code' => in_array($mapping, ['pinduoduo', 'dewu'], true) ? 'b.auto_id' : ($mapping === 'taobao' ? 'b.p_auto_id' : 'b.Id'),
|
||||
'occurred_at' => 'b.fee_time',
|
||||
'income' => 'b.amount',
|
||||
'expense' => 'b.amount',
|
||||
@ -238,8 +242,10 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
}
|
||||
$rows = $batch->select('b.*')->orderBy($sortColumn, $direction)
|
||||
->orderBy('b.Id', $direction)->limit(1000)->get();
|
||||
$productNames = $mapping === 'taobao' ? $this->productNames($rows->all()) : [];
|
||||
$importedRows = $mapping === 'taobao' ? $this->importedRows($rows->all()) : [];
|
||||
foreach ($rows as $row) {
|
||||
yield $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping);
|
||||
yield $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping, $productNames, $importedRows);
|
||||
}
|
||||
$last = $rows->last();
|
||||
} while ($rows->count() === 1000);
|
||||
@ -321,7 +327,7 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
|
||||
private function dailySummarySupports(array $filters): bool
|
||||
{
|
||||
return ($filters['alipay_account'] ?? '') === ''
|
||||
return ($filters['source_channel'] ?? '') === ''
|
||||
&& ($filters['bill_item'] ?? '') === ''
|
||||
&& ($filters['keyword'] ?? '') === '';
|
||||
}
|
||||
@ -377,8 +383,8 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
$query->whereIn('b.shop_id', $shopIds);
|
||||
}
|
||||
}
|
||||
if (($filters['alipay_account'] ?? '') !== '') {
|
||||
$query->where('b.channel', 'like', '%' . $this->escapeLike((string) $filters['alipay_account']) . '%');
|
||||
if (($filters['source_channel'] ?? '') !== '') {
|
||||
$query->where('b.channel', 'like', '%' . $this->escapeLike((string) $filters['source_channel']) . '%');
|
||||
}
|
||||
if (($filters['bill_item'] ?? '') !== '') {
|
||||
$query->where('b.fee_name', 'like', '%' . $this->escapeLike((string) $filters['bill_item']) . '%');
|
||||
@ -488,6 +494,47 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @param array<int,object> $rows @return array<string,string> */
|
||||
private function productNames(array $rows): array
|
||||
{
|
||||
$skuIds = collect($rows)->map(fn (object $row): ?string => $row->sku_id ?: $row->shop_sku_id)
|
||||
->filter()->unique()->values()->all();
|
||||
if ($skuIds === []) return [];
|
||||
|
||||
try {
|
||||
$dataset = $this->datasets->get(self::SKU_DATASET);
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
if (! $this->mirror->ready($dataset) || ! Schema::connection($this->mirror->connection()->getName())->hasTable('ops_jst_sku')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->mirror->connection()->table('ops_jst_sku')
|
||||
->whereIn('sku_id', $skuIds)->pluck('name', 'sku_id')
|
||||
->mapWithKeys(static fn (mixed $name, mixed $id): array => [(string) $id => (string) $name])
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @param array<int,object> $rows @return array<string,object> */
|
||||
private function importedRows(array $rows): array
|
||||
{
|
||||
if (! Schema::hasTable('ec_alipay_bills')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$recordCodes = collect($rows)->pluck('p_auto_id')->filter()->unique()->values()->all();
|
||||
if ($recordCodes === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('ec_alipay_bills')
|
||||
->where('company_id', (int) (company_id() ?: 1))
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('record_code', $recordCodes)
|
||||
->get()->keyBy(fn (object $row): string => (string) $row->record_code)->all();
|
||||
}
|
||||
|
||||
/** @param array<int,object> $rows */
|
||||
public function mapDouyinRows(array $rows): array
|
||||
{
|
||||
@ -495,44 +542,63 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
return array_map(fn (object $row): array => $this->mapRowFor($row, $shops[(string) $row->shop_id] ?? null, 'douyin'), $rows);
|
||||
}
|
||||
|
||||
private function mapRow(object $row, ?string $shopName): array
|
||||
private function mapRow(object $row, ?string $shopName, array $productNames = [], array $importedRows = []): array
|
||||
{
|
||||
$amount = abs((float) ($row->amount ?? 0));
|
||||
$income = (string) ($row->inout_type ?? '') === '1' ? $amount : 0.0;
|
||||
$expense = (string) ($row->inout_type ?? '') === '-1' ? $amount : 0.0;
|
||||
|
||||
return [
|
||||
$mapped = [
|
||||
'id' => (string) $row->Id,
|
||||
'record_code' => (string) $row->Id,
|
||||
'record_code' => (string) ($row->p_auto_id ?: $row->Id),
|
||||
'store_name' => $shopName,
|
||||
'custom_remark' => null,
|
||||
'bill_item' => $row->fee_name,
|
||||
'alipay_account' => $row->channel,
|
||||
'account_order_no' => $row->primary_key,
|
||||
'business_serial_no' => $row->auto_id,
|
||||
'merchant_order_no' => $row->p_auto_id,
|
||||
'business_base_order_no' => $row->so_id,
|
||||
'goods_name' => $row->sku_id ?: $row->shop_sku_id,
|
||||
'alipay_account' => null,
|
||||
'account_order_no' => null,
|
||||
'business_serial_no' => null,
|
||||
'merchant_order_no' => null,
|
||||
'business_base_order_no' => null,
|
||||
'goods_name' => $productNames[(string) ($row->sku_id ?: $row->shop_sku_id)] ?? null,
|
||||
'counterparty_account' => null,
|
||||
'occurred_at' => $this->dateTime($row->fee_time),
|
||||
'income' => $income,
|
||||
'expense' => $expense,
|
||||
'balance' => round($income - $expense, 2),
|
||||
'business_type' => $this->directionName($row->inout_type),
|
||||
'balance' => null,
|
||||
'business_type' => null,
|
||||
'remark' => null,
|
||||
'online_order_no' => $row->so_id,
|
||||
'online_real_store' => $shopName,
|
||||
'business_description' => $row->combine_type,
|
||||
'business_bill_source' => $row->channel,
|
||||
'business_order_no' => $row->p_auto_id,
|
||||
'business_description' => null,
|
||||
'business_bill_source' => null,
|
||||
'business_order_no' => null,
|
||||
'created_at' => $this->dateTime($row->createTime),
|
||||
'updated_at' => $this->dateTime($row->updateTime),
|
||||
'modifier' => '',
|
||||
];
|
||||
|
||||
$imported = $importedRows[$mapped['record_code']] ?? null;
|
||||
if ($imported === null) {
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'custom_remark', 'alipay_account', 'account_order_no', 'business_serial_no',
|
||||
'merchant_order_no', 'business_base_order_no', 'counterparty_account', 'balance',
|
||||
'business_type', 'remark', 'business_description', 'business_bill_source',
|
||||
'business_order_no', 'modifier',
|
||||
] as $field) {
|
||||
$mapped[$field] = $imported->{$field} ?? $mapped[$field];
|
||||
}
|
||||
if ($mapped['goods_name'] === null || $mapped['goods_name'] === '') {
|
||||
$mapped['goods_name'] = $imported->goods_name ?? null;
|
||||
}
|
||||
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function mapRowFor(object $row, ?string $shopName, string $mapping): array
|
||||
private function mapRowFor(object $row, ?string $shopName, string $mapping, array $productNames = [], array $importedRows = []): array
|
||||
{
|
||||
if ($mapping === 'pinduoduo') {
|
||||
return $this->mapPinduoduoRow($row, $shopName);
|
||||
@ -543,7 +609,7 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
}
|
||||
|
||||
if ($mapping !== 'douyin') {
|
||||
return $this->mapRow($row, $shopName);
|
||||
return $this->mapRow($row, $shopName, $productNames, $importedRows);
|
||||
}
|
||||
|
||||
$amount = abs((float) ($row->amount ?? 0));
|
||||
|
||||
@ -32,6 +32,8 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
|
||||
private const INVENTORY_TABLE = 'third_party_warehouse_inventory_snapshots';
|
||||
|
||||
private const SUBWAREHOUSE_INVENTORY_TABLE = 'third_party_warehouse_inventory_subwarehouse_snapshots';
|
||||
|
||||
private const AUTHORITATIVE_STATE_TABLE = 'third_party_warehouse_authoritative_anchor_states';
|
||||
|
||||
private const AUTHORITATIVE_ANCHOR_TABLE = 'third_party_warehouse_authoritative_anchors';
|
||||
@ -40,12 +42,22 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
|
||||
private const AUTHORITATIVE_DATASET_KEY = 'jiansun.third-party-warehouse-storage.authoritative-anchor';
|
||||
|
||||
private const INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE = 'inventory_report_authoritative_states';
|
||||
|
||||
private const INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE = 'inventory_report_authoritative_rows';
|
||||
|
||||
private const INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY = 'jiansun.inventory-report.authoritative-month';
|
||||
|
||||
private const PRODUCT_TABLE = 'ops_jst_sku';
|
||||
|
||||
private const WAREHOUSE_TABLE = 'ops_jst_warehouse_info';
|
||||
|
||||
private const GENERIC_STATE_TABLE = 'business_mirror_table_states';
|
||||
|
||||
private const PRODUCT_DATASET_KEY = 'operations_replica.rpt.jst_sku';
|
||||
|
||||
private const WAREHOUSE_DATASET_KEY = 'operations_replica.rpt.jst_warehouseInfo';
|
||||
|
||||
public const START_DATE = '2026-08-01';
|
||||
|
||||
/** @var array<string,string> */
|
||||
@ -64,6 +76,45 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
'other_out' => '其它出库',
|
||||
];
|
||||
|
||||
/** @var array<int,string> */
|
||||
private const SUBWAREHOUSE_SUFFIXES = [
|
||||
1 => '主仓',
|
||||
2 => '销退仓',
|
||||
3 => '进货仓',
|
||||
4 => '次品仓',
|
||||
6 => '自定义仓1',
|
||||
7 => '自定义仓2',
|
||||
8 => '自定义仓3',
|
||||
];
|
||||
|
||||
/**
|
||||
* 聚水潭“按商品、按仓库、按月”导出中使用的实际子仓名。
|
||||
* 其余仓库按“仓储方 + 子仓后缀”生成,不再只显示通用类型名。
|
||||
*
|
||||
* @var array<string,array<int,string>>
|
||||
*/
|
||||
private const SPECIAL_SUBWAREHOUSE_NAMES = [
|
||||
'常州简尚家居有限公司' => [
|
||||
1 => '配件单仓',
|
||||
],
|
||||
'简尚-横林二仓' => [
|
||||
6 => '客退临时上架仓',
|
||||
7 => '数量校验仓',
|
||||
],
|
||||
'简尚-新苏北仓' => [
|
||||
6 => '新苏北客退上架仓',
|
||||
],
|
||||
'简尚-睢宁仓' => [
|
||||
6 => '睢宁客退上架仓',
|
||||
],
|
||||
'简尚-河南洛宁仓' => [
|
||||
6 => '河南洛宁客退上架仓',
|
||||
],
|
||||
'简尚-山东青岗集仓' => [
|
||||
7 => '青岗集客退上架仓',
|
||||
],
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private const OUTPUT_COLUMNS = [
|
||||
'owner', 'date', 'sku_code', 'image_url', 'product_name', 'short_name',
|
||||
@ -123,6 +174,15 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
&& $this->dependenciesReady();
|
||||
}
|
||||
|
||||
public function allWarehousesReady(): bool
|
||||
{
|
||||
$state = $this->state();
|
||||
|
||||
return $this->ready()
|
||||
&& (int) ($state['scope_version'] ?? 1) >= 2
|
||||
&& $this->warehouseDependencyReady();
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function state(): ?array
|
||||
{
|
||||
@ -218,6 +278,350 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品价格管理的进销存报表与三方仓共用同一套已确认流水、
|
||||
* 库存锚点和业务类型口径,只在这里将日明细再汇总为“商品 + 仓库 + 月”。
|
||||
*
|
||||
* @param array<string,mixed> $filters
|
||||
* @return array{data:list<object>,total:int,state:array<string,mixed>,sync:array<string,mixed>}
|
||||
*/
|
||||
public function paginateMonthlyInventoryReport(
|
||||
array $filters,
|
||||
int $page,
|
||||
int $perPage,
|
||||
bool $includeTotal = true,
|
||||
?array $state = null,
|
||||
): array
|
||||
{
|
||||
$state ??= $this->requireReadyState();
|
||||
if ((int) ($state['scope_version'] ?? 1) < 2) {
|
||||
throw new RuntimeException('inventory_report_all_warehouses_not_ready');
|
||||
}
|
||||
$page = max($page, 1);
|
||||
$perPage = min(max($perPage, 1), 5000);
|
||||
$period = $this->period([
|
||||
'dimension' => 'month',
|
||||
'period' => $filters['month'] ?? '',
|
||||
]);
|
||||
$authoritative = $this->monthlyInventoryAuthoritativeQuery($period, $filters);
|
||||
if ($authoritative !== null) {
|
||||
$total = $includeTotal ? (clone $authoritative)->count() : 0;
|
||||
$rows = (clone $authoritative)
|
||||
->orderBy('warehouse_provider')
|
||||
->orderBy('warehouse')
|
||||
->orderBy('product_code')
|
||||
->forPage($page, $perPage)
|
||||
->get()
|
||||
->map(static fn (object $row): object => (object) [
|
||||
'owner' => $row->owner,
|
||||
'month' => $row->report_month,
|
||||
'warehouse_provider' => $row->warehouse_provider,
|
||||
'warehouse' => $row->warehouse,
|
||||
'product_code' => $row->product_code,
|
||||
'category' => $row->category,
|
||||
'opening_quantity' => $row->opening_quantity,
|
||||
'closing_quantity' => $row->closing_quantity,
|
||||
'processing_in' => $row->processing_in,
|
||||
'processing_out' => $row->processing_out,
|
||||
'purchase_in' => $row->purchase_in,
|
||||
'purchase_return' => $row->purchase_return,
|
||||
'other_in' => $row->other_in,
|
||||
'other_return' => $row->other_return,
|
||||
'sales_out' => $row->sales_out,
|
||||
'sales_return' => $row->sales_return,
|
||||
'stocktake' => $row->stocktake,
|
||||
'transfer_out' => $row->transfer_out,
|
||||
'transfer_in' => $row->transfer_in,
|
||||
'opening' => $row->opening,
|
||||
'other_out' => $row->other_out,
|
||||
])
|
||||
->all();
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'total' => $total,
|
||||
'state' => $state,
|
||||
'sync' => $this->syncStatus($state),
|
||||
];
|
||||
}
|
||||
$headerGeneration = $this->generation($state, 'active_header_generation');
|
||||
$itemGeneration = $this->generation($state, 'active_item_generation');
|
||||
$inventoryGeneration = $this->generation($state, 'active_inventory_generation');
|
||||
if ($headerGeneration === null || $itemGeneration === null || $inventoryGeneration === null) {
|
||||
throw new RuntimeException('inventory_report_mirror_not_ready');
|
||||
}
|
||||
|
||||
$anchorAt = $this->stateAnchor($state);
|
||||
$rangeStart = min($period['start'], $anchorAt);
|
||||
$rangeEnd = max($period['end'], $anchorAt);
|
||||
$latestProducts = $this->connection->table(self::PRODUCT_TABLE)
|
||||
->where('sku_type', 'normal')->whereNotNull('sku_id')->where('sku_id', '!=', '')
|
||||
->groupBy('sku_id')->select('sku_id')->selectRaw('MAX(autoid) AS autoid');
|
||||
$latestWarehouses = $this->connection->table(self::WAREHOUSE_TABLE)
|
||||
->whereNotNull('wms_co_id')->groupBy('wms_co_id')
|
||||
->select('wms_co_id')->selectRaw('MAX(Id) AS Id');
|
||||
$inventoryDimensions = $this->connection->table(self::SUBWAREHOUSE_INVENTORY_TABLE)
|
||||
->where('generation', $inventoryGeneration)
|
||||
->where('qty', '!=', 0)
|
||||
->whereNotNull('sku_code')->where('sku_code', '!=', '')
|
||||
->select(['wms_co_id', 'wh_id', 'sku_code as inventory_sku_code', 'captured_at'])
|
||||
->selectRaw('qty AS snapshot_qty');
|
||||
$candidates = $this->connection->query()->fromSub($inventoryDimensions, 'pairs')
|
||||
->joinSub($latestProducts, 'latest_product', 'latest_product.sku_id', '=', 'pairs.inventory_sku_code')
|
||||
->join(self::PRODUCT_TABLE . ' as s', function ($join): void {
|
||||
$join->on('s.sku_id', '=', 'latest_product.sku_id')
|
||||
->on('s.autoid', '=', 'latest_product.autoid');
|
||||
})
|
||||
->select([
|
||||
'pairs.wms_co_id', 'pairs.wh_id', 'pairs.inventory_sku_code',
|
||||
's.sku_id as sku_code', 's.category',
|
||||
]);
|
||||
$productCode = trim((string) ($filters['product_code'] ?? ''));
|
||||
if ($productCode !== '') {
|
||||
$candidates->where('s.sku_id', $productCode);
|
||||
}
|
||||
$warehouseFilter = trim((string) ($filters['warehouse_provider'] ?? ''));
|
||||
if ($warehouseFilter !== '') {
|
||||
$warehouseIds = $this->connection->query()->fromSub(
|
||||
clone $latestWarehouses,
|
||||
'filter_latest_warehouse',
|
||||
)->join(self::WAREHOUSE_TABLE . ' as filter_warehouse', function ($join): void {
|
||||
$join->on('filter_warehouse.wms_co_id', '=', 'filter_latest_warehouse.wms_co_id')
|
||||
->on('filter_warehouse.Id', '=', 'filter_latest_warehouse.Id');
|
||||
});
|
||||
$this->containsFilter($warehouseIds, 'filter_warehouse.name', $warehouseFilter);
|
||||
$candidates->whereIn('pairs.wms_co_id', $warehouseIds->select('filter_warehouse.wms_co_id'));
|
||||
}
|
||||
$categoryFilter = trim((string) ($filters['category'] ?? ''));
|
||||
if ($categoryFilter !== '') {
|
||||
$this->containsFilter($candidates, 's.category', $categoryFilter);
|
||||
}
|
||||
|
||||
$total = $includeTotal ? (clone $candidates)->count() : 0;
|
||||
$pagePairs = (clone $candidates)
|
||||
->orderBy('pairs.wms_co_id')->orderBy('pairs.wh_id')->orderBy('s.sku_id')
|
||||
->forPage($page, $perPage);
|
||||
$pairRows = (clone $pagePairs)->get();
|
||||
if ($pairRows->isEmpty()) {
|
||||
return [
|
||||
'data' => [],
|
||||
'total' => $total,
|
||||
'state' => $state,
|
||||
'sync' => $this->syncStatus($state),
|
||||
];
|
||||
}
|
||||
|
||||
$dimensionedPagePairs = $this->connection->query()->fromSub($pagePairs, 'pairs')
|
||||
->leftJoinSub($latestWarehouses, 'latest_warehouse', 'latest_warehouse.wms_co_id', '=', 'pairs.wms_co_id')
|
||||
->leftJoin(self::WAREHOUSE_TABLE . ' as w', function ($join): void {
|
||||
$join->on('w.wms_co_id', '=', 'latest_warehouse.wms_co_id')
|
||||
->on('w.Id', '=', 'latest_warehouse.Id');
|
||||
})
|
||||
->select([
|
||||
'pairs.wms_co_id', 'pairs.wh_id', 'pairs.inventory_sku_code',
|
||||
'pairs.sku_code', 'pairs.category', 'w.name as warehouse_name',
|
||||
]);
|
||||
$anchors = $this->connection->query()->fromSub($dimensionedPagePairs, 'pairs')
|
||||
->leftJoinSub(clone $inventoryDimensions, 'inventory', function ($join): void {
|
||||
$join->on('inventory.wms_co_id', '=', 'pairs.wms_co_id')
|
||||
->on('inventory.inventory_sku_code', '=', 'pairs.inventory_sku_code')
|
||||
->on('inventory.wh_id', '=', 'pairs.wh_id');
|
||||
})
|
||||
->select([
|
||||
'pairs.wms_co_id', 'pairs.wh_id', 'pairs.inventory_sku_code',
|
||||
'pairs.sku_code', 'pairs.category', 'pairs.warehouse_name',
|
||||
])
|
||||
->selectRaw('COALESCE(inventory.snapshot_qty, 0) AS snapshot_qty')
|
||||
->selectRaw('COALESCE(inventory.captured_at, ?) AS snapshot_at', [$anchorAt]);
|
||||
|
||||
$movements = $this->confirmedMovements($headerGeneration, $itemGeneration, null)
|
||||
->where('h.occurred_at', '>=', $rangeStart)
|
||||
->where('h.occurred_at', '<', $rangeEnd)
|
||||
->whereIn('h.wms_co_id', $pairRows->pluck('wms_co_id')->unique()->values()->all())
|
||||
->whereIn('i.sku_code', $pairRows->pluck('inventory_sku_code')->unique()->values()->all())
|
||||
->select([
|
||||
'h.wms_co_id', 'h.io_type', 'h.occurred_at', 'i.sku_code as inventory_sku_code',
|
||||
'i.qty as movement_qty', 'i.qty_type as movement_qty_type',
|
||||
])
|
||||
->selectRaw('COALESCE(NULLIF(h.wh_id, 0), 1) AS wh_id');
|
||||
$signed = '(COALESCE(mv.movement_qty, 0) * COALESCE(mv.movement_qty_type, 0))';
|
||||
$balanceAt = static fn (string $target): array => [
|
||||
'a.snapshot_qty + COALESCE(SUM(CASE '
|
||||
. 'WHEN a.snapshot_at < ? AND mv.occurred_at >= a.snapshot_at AND mv.occurred_at < ? THEN ' . $signed . ' '
|
||||
. 'WHEN a.snapshot_at >= ? AND mv.occurred_at >= ? AND mv.occurred_at < a.snapshot_at THEN -' . $signed . ' '
|
||||
. 'ELSE 0 END), 0)',
|
||||
[$target, $target, $target, $target],
|
||||
];
|
||||
[$openingSql, $openingBindings] = $balanceAt($period['start']);
|
||||
[$closingSql, $closingBindings] = $balanceAt($period['end']);
|
||||
$monthly = $this->connection->query()->fromSub($anchors, 'a')
|
||||
->leftJoinSub($movements, 'mv', function ($join): void {
|
||||
$join->on('mv.wms_co_id', '=', 'a.wms_co_id')
|
||||
->on('mv.wh_id', '=', 'a.wh_id')
|
||||
->on('mv.inventory_sku_code', '=', 'a.inventory_sku_code');
|
||||
})
|
||||
->groupBy([
|
||||
'a.wms_co_id', 'a.wh_id', 'a.inventory_sku_code', 'a.sku_code',
|
||||
'a.category', 'a.warehouse_name',
|
||||
'a.snapshot_qty', 'a.snapshot_at',
|
||||
])
|
||||
->select(['a.wms_co_id', 'a.wh_id', 'a.sku_code', 'a.category', 'a.warehouse_name'])
|
||||
->selectRaw($openingSql . ' AS opening_qty', $openingBindings)
|
||||
->selectRaw($closingSql . ' AS closing_qty', $closingBindings);
|
||||
foreach (self::MOVEMENT_COLUMNS as $column => $type) {
|
||||
$monthly->selectRaw(
|
||||
'COALESCE(SUM(CASE WHEN mv.occurred_at >= ? AND mv.occurred_at < ? '
|
||||
. 'AND mv.io_type = ? THEN ' . $signed . ' ELSE 0 END), 0) AS ' . $column,
|
||||
[$period['start'], $period['end'], $type],
|
||||
);
|
||||
}
|
||||
$monthly->selectRaw('0 AS opening_aux');
|
||||
|
||||
$rows = $this->connection->query()->fromSub($monthly, 'm')
|
||||
->select('m.*')->orderBy('m.warehouse_name')->orderBy('m.wh_id')->orderBy('m.sku_code')->get()
|
||||
->map(function (object $row) use ($period): object {
|
||||
$warehouse = trim((string) ($row->warehouse_name ?? ''));
|
||||
$subwarehouseId = (int) ($row->wh_id ?? 0);
|
||||
$subwarehouse = $this->subwarehouseName($warehouse, $subwarehouseId);
|
||||
|
||||
return (object) [
|
||||
'owner' => (string) config(
|
||||
'upstream_mirrors.third_party_warehouse_storage.owner_name',
|
||||
'常州简尚家居有限公司',
|
||||
),
|
||||
'month' => Carbon::parse($period['start'])->toDateString(),
|
||||
'warehouse_provider' => $warehouse,
|
||||
'warehouse' => $subwarehouse,
|
||||
'product_code' => (string) ($row->sku_code ?? ''),
|
||||
'category' => (string) ($row->category ?? ''),
|
||||
'opening_quantity' => $row->opening_qty,
|
||||
'closing_quantity' => $row->closing_qty,
|
||||
'processing_in' => $row->processing_in,
|
||||
'processing_out' => $row->processing_out,
|
||||
'purchase_in' => $row->purchase_in,
|
||||
'purchase_return' => $row->purchase_return,
|
||||
'other_in' => $row->other_in,
|
||||
'other_return' => $row->other_return,
|
||||
'sales_out' => $row->sales_out,
|
||||
'sales_return' => $row->sales_return,
|
||||
'stocktake' => $row->stocktake,
|
||||
'transfer_out' => $row->transfer_out,
|
||||
'transfer_in' => $row->transfer_in,
|
||||
'opening' => $row->opening_aux,
|
||||
'other_out' => $row->other_out,
|
||||
];
|
||||
})->all();
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'total' => $total,
|
||||
'state' => $state,
|
||||
'sync' => $this->syncStatus($state),
|
||||
];
|
||||
}
|
||||
|
||||
private function subwarehouseName(string $warehouseProvider, int $subwarehouseId): string
|
||||
{
|
||||
$specialName = self::SPECIAL_SUBWAREHOUSE_NAMES[$warehouseProvider][$subwarehouseId] ?? null;
|
||||
if ($specialName !== null) {
|
||||
return $specialName;
|
||||
}
|
||||
|
||||
$suffix = self::SUBWAREHOUSE_SUFFIXES[$subwarehouseId] ?? '其他子仓(' . $subwarehouseId . ')';
|
||||
|
||||
return $warehouseProvider === '' ? $suffix : $warehouseProvider . $suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $filters
|
||||
* @return Generator<int,object>
|
||||
*/
|
||||
public function exportMonthlyInventoryReportRows(array $filters, int $batchSize = 2000): Generator
|
||||
{
|
||||
$batchSize = min(max($batchSize, 1), 5000);
|
||||
$page = 1;
|
||||
$exported = 0;
|
||||
$total = null;
|
||||
$state = $this->requireReadyState();
|
||||
|
||||
while (true) {
|
||||
$result = $this->paginateMonthlyInventoryReport(
|
||||
$filters,
|
||||
$page,
|
||||
$batchSize,
|
||||
$total === null,
|
||||
$state,
|
||||
);
|
||||
if ($total === null) {
|
||||
$total = (int) ($result['total'] ?? 0);
|
||||
}
|
||||
|
||||
$rows = $result['data'] ?? [];
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
foreach ($rows as $row) {
|
||||
yield $row;
|
||||
}
|
||||
|
||||
$exported += count($rows);
|
||||
if ($exported >= $total || count($rows) < $batchSize) {
|
||||
return;
|
||||
}
|
||||
$page++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户确认的聚水潭月报保留了“仓储方 + 子仓 + 商品”维度。
|
||||
* 命中同月权威数据时整月直接读取,不再用当前库存倒推历史子仓余额。
|
||||
*
|
||||
* @param array{start:string,end:string} $period
|
||||
* @param array<string,mixed> $filters
|
||||
*/
|
||||
private function monthlyInventoryAuthoritativeQuery(array $period, array $filters): ?Builder
|
||||
{
|
||||
try {
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
if (! $schema->hasTable(self::INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE)
|
||||
|| ! $schema->hasTable(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$month = Carbon::parse($period['start'])->startOfMonth()->toDateString();
|
||||
$state = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE)
|
||||
->where('dataset_key', self::INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY)
|
||||
->whereNotNull('active_generation')
|
||||
->whereDate('source_month', $month)
|
||||
->first();
|
||||
if ($state === null || trim((string) $state->active_generation) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE)
|
||||
->where('generation', (string) $state->active_generation)
|
||||
->whereDate('report_month', $month)
|
||||
->select([
|
||||
'owner', 'report_month', 'warehouse_provider', 'warehouse',
|
||||
'product_code', 'category', 'opening_quantity', 'closing_quantity',
|
||||
'processing_in', 'processing_out', 'purchase_in', 'purchase_return',
|
||||
'other_in', 'other_return', 'sales_out', 'sales_return', 'stocktake',
|
||||
'transfer_out', 'transfer_in', 'opening', 'other_out',
|
||||
]);
|
||||
|
||||
$productCode = trim((string) ($filters['product_code'] ?? ''));
|
||||
if ($productCode !== '') {
|
||||
$query->where('product_code', $productCode);
|
||||
}
|
||||
$this->containsFilter($query, 'warehouse_provider', $filters['warehouse_provider'] ?? '');
|
||||
$this->containsFilter($query, 'category', $filters['category'] ?? '');
|
||||
|
||||
return $query;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $filters
|
||||
* @return array{count:int,opening_qty:float,closing_qty:float}
|
||||
@ -536,15 +940,18 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
return $dates;
|
||||
}
|
||||
|
||||
private function confirmedMovements(string $headerGeneration, string $itemGeneration): Builder
|
||||
private function confirmedMovements(
|
||||
string $headerGeneration,
|
||||
string $itemGeneration,
|
||||
?int $warehouseId = self::WAREHOUSE_ID,
|
||||
): Builder
|
||||
{
|
||||
return $this->connection->table(self::ITEM_TABLE . ' as i')
|
||||
$query = $this->connection->table(self::ITEM_TABLE . ' as i')
|
||||
->join(self::HEADER_TABLE . ' as h', function ($join) use ($headerGeneration): void {
|
||||
$join->on('h.io_id', '=', 'i.io_id')
|
||||
->where('h.generation', '=', $headerGeneration);
|
||||
})
|
||||
->where('i.generation', $itemGeneration)
|
||||
->where('h.wms_co_id', self::WAREHOUSE_ID)
|
||||
->where('h.status', 'Confirmed')
|
||||
->whereIn('h.io_type', array_values(self::MOVEMENT_COLUMNS))
|
||||
->whereNotExists(function (Builder $newer) use ($headerGeneration): void {
|
||||
@ -554,6 +961,12 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
->whereColumn('newer.id', '>', 'h.id');
|
||||
})
|
||||
->whereNotNull('i.sku_code')->where('i.sku_code', '!=', '');
|
||||
|
||||
if ($warehouseId !== null) {
|
||||
$query->where('h.wms_co_id', $warehouseId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -775,6 +1188,23 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
}
|
||||
}
|
||||
|
||||
private function warehouseDependencyReady(): bool
|
||||
{
|
||||
try {
|
||||
if (! $this->connection->getSchemaBuilder()->hasTable(self::WAREHOUSE_TABLE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->connection->table(self::GENERIC_STATE_TABLE)
|
||||
->where('dataset_key', self::WAREHOUSE_DATASET_KEY)
|
||||
->whereNotNull('initial_completed_at')
|
||||
->whereIn('status', ['succeeded', 'running', 'failed'])
|
||||
->exists();
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $state */
|
||||
private function generation(array $state, string $key): ?string
|
||||
{
|
||||
|
||||
@ -9,6 +9,7 @@ class ThirdPartyWarehouseStorageUpstreamRepository
|
||||
{
|
||||
public const SOURCE_KEY = 'jiansun';
|
||||
|
||||
/** 三方仓页面仍固定读取该仓;共享流水镜像覆盖全部仓库。 */
|
||||
public const WAREHOUSE_ID = 15045364;
|
||||
|
||||
public const START_DATE = '2026-08-01';
|
||||
@ -22,8 +23,7 @@ class ThirdPartyWarehouseStorageUpstreamRepository
|
||||
public function headerHighWatermark(): int
|
||||
{
|
||||
return (int) $this->query()->scalar(
|
||||
'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inoutWater] WHERE [wms_co_id] = ?',
|
||||
[self::WAREHOUSE_ID],
|
||||
'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inoutWater]',
|
||||
);
|
||||
}
|
||||
|
||||
@ -39,6 +39,7 @@ SELECT
|
||||
h.[Id] AS source_id,
|
||||
h.[io_id],
|
||||
h.[wms_co_id],
|
||||
h.[wh_id],
|
||||
h.[status],
|
||||
h.[type] AS io_type,
|
||||
CONVERT(varchar(27), h.[io_date], 126) AS occurred_at,
|
||||
@ -46,14 +47,13 @@ SELECT
|
||||
FROM [dbo].[jst_inoutWater] h
|
||||
WHERE h.[Id] > ?
|
||||
AND h.[Id] <= ?
|
||||
AND h.[wms_co_id] = ?
|
||||
AND h.[io_date] >= ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM [dbo].[jst_inoutWater] newer
|
||||
WHERE newer.[io_id] = h.[io_id] AND newer.[Id] > h.[Id]
|
||||
)
|
||||
ORDER BY h.[Id]
|
||||
SQL, [$afterId, $throughId, self::WAREHOUSE_ID, self::START_DATE]);
|
||||
SQL, [$afterId, $throughId, self::START_DATE]);
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
@ -70,6 +70,7 @@ SQL, [$afterId, $throughId, self::WAREHOUSE_ID, self::START_DATE]);
|
||||
[Id] AS source_id,
|
||||
[io_id],
|
||||
[wms_co_id],
|
||||
[wh_id],
|
||||
[status],
|
||||
[type] AS io_type,
|
||||
CONVERT(varchar(27), [io_date], 126) AS occurred_at,
|
||||
@ -103,7 +104,7 @@ SQL, [$cursorAt, $throughAt, $cursorAt, $cursorAt, $cursorId]);
|
||||
foreach (array_chunk($ioIds, 500) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$batch = $this->query()->select(
|
||||
'SELECT h.[Id] AS source_id, h.[io_id], h.[wms_co_id], h.[status], '
|
||||
'SELECT h.[Id] AS source_id, h.[io_id], h.[wms_co_id], h.[wh_id], h.[status], '
|
||||
. 'h.[type] AS io_type, CONVERT(varchar(27), h.[io_date], 126) AS occurred_at, '
|
||||
. 'CONVERT(varchar(27), h.[modified], 126) AS modified_at '
|
||||
. 'FROM [dbo].[jst_inoutWater] h '
|
||||
@ -165,8 +166,7 @@ SQL, [$cursorAt, $throughAt, $cursorAt, $cursorAt, $cursorId]);
|
||||
public function inventoryHighWatermark(): int
|
||||
{
|
||||
return (int) $this->query()->scalar(
|
||||
'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inventoryInfo] WHERE [wms_co_id] = ?',
|
||||
[self::WAREHOUSE_ID],
|
||||
'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inventoryInfo]',
|
||||
);
|
||||
}
|
||||
|
||||
@ -185,13 +185,18 @@ SQL, [$cursorAt, $throughAt, $cursorAt, $cursorAt, $cursorId]);
|
||||
[wms_co_id],
|
||||
[sku_id] AS sku_code,
|
||||
[qty],
|
||||
[return_qty],
|
||||
[in_qty],
|
||||
[defective_qty],
|
||||
[customize_qty_1],
|
||||
[customize_qty_2],
|
||||
[customize_qty_3],
|
||||
CONVERT(varchar(27), [modified], 126) AS source_modified_at
|
||||
FROM [dbo].[jst_inventoryInfo]
|
||||
WHERE [Id] > ?
|
||||
AND [Id] <= ?
|
||||
AND [wms_co_id] = ?
|
||||
ORDER BY [Id]
|
||||
SQL, [$afterId, $highWatermark, self::WAREHOUSE_ID]);
|
||||
SQL, [$afterId, $highWatermark]);
|
||||
}
|
||||
|
||||
private function query(): UpstreamReadOnlyQuery
|
||||
|
||||
@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use RuntimeException;
|
||||
use SimpleXMLElement;
|
||||
use Throwable;
|
||||
use XMLReader;
|
||||
use ZipArchive;
|
||||
|
||||
class InventoryReportAuthoritativeImporter
|
||||
{
|
||||
public const DATASET_KEY = 'jiansun.inventory-report.authoritative-month';
|
||||
|
||||
private const STATE_TABLE = 'inventory_report_authoritative_states';
|
||||
|
||||
private const ROW_TABLE = 'inventory_report_authoritative_rows';
|
||||
|
||||
/** @var list<string> */
|
||||
private const REQUIRED_HEADERS = [
|
||||
'货主', '月份', '仓储方', '仓库', '商品编码', '分类', '期初数量', '期末数量',
|
||||
'加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货',
|
||||
'销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private const MOVEMENT_HEADERS = [
|
||||
'加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货',
|
||||
'销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库',
|
||||
];
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(DatabaseManager $databases)
|
||||
{
|
||||
$this->connection = $databases->connection(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{status:string,source_rows:int,source_month:string,generation:string} */
|
||||
public function import(string $path): array
|
||||
{
|
||||
$path = $this->validatedPath($path);
|
||||
$this->assertSchemaReady();
|
||||
|
||||
$generation = (string) Str::uuid();
|
||||
$now = now();
|
||||
$hash = hash_file('sha256', $path);
|
||||
if (! is_string($hash)) {
|
||||
throw new RuntimeException('无法计算进销存月报文件摘要。');
|
||||
}
|
||||
|
||||
$this->connection->table(self::STATE_TABLE)->insertOrIgnore([
|
||||
'dataset_key' => self::DATASET_KEY,
|
||||
'status' => 'pending',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$this->connection->table(self::STATE_TABLE)
|
||||
->where('dataset_key', self::DATASET_KEY)
|
||||
->update([
|
||||
'status' => 'running',
|
||||
'pending_generation' => $generation,
|
||||
'last_attempted_at' => $now,
|
||||
'last_error_code' => null,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
try {
|
||||
$source = $this->readAndStore($path, $generation, $now);
|
||||
$this->connection->transaction(function () use (
|
||||
$generation,
|
||||
$path,
|
||||
$hash,
|
||||
$source,
|
||||
$now,
|
||||
): void {
|
||||
$this->connection->table(self::STATE_TABLE)
|
||||
->where('dataset_key', self::DATASET_KEY)
|
||||
->update([
|
||||
'status' => 'succeeded',
|
||||
'active_generation' => $generation,
|
||||
'pending_generation' => null,
|
||||
'source_filename' => mb_substr(basename($path), 0, 255),
|
||||
'source_sha256' => $hash,
|
||||
'source_month' => $source['source_month'],
|
||||
'source_row_count' => $source['source_rows'],
|
||||
'last_succeeded_at' => $now,
|
||||
'last_error_code' => null,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
});
|
||||
} catch (Throwable $exception) {
|
||||
$this->connection->table(self::ROW_TABLE)
|
||||
->where('generation', $generation)
|
||||
->delete();
|
||||
$this->connection->table(self::STATE_TABLE)
|
||||
->where('dataset_key', self::DATASET_KEY)
|
||||
->update([
|
||||
'status' => 'failed',
|
||||
'pending_generation' => null,
|
||||
'last_error_code' => class_basename($exception),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->connection->table(self::ROW_TABLE)
|
||||
->where('generation', '!=', $generation)
|
||||
->delete();
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'succeeded',
|
||||
'source_rows' => $source['source_rows'],
|
||||
'source_month' => $source['source_month'],
|
||||
'generation' => $generation,
|
||||
];
|
||||
}
|
||||
|
||||
private function validatedPath(string $path): string
|
||||
{
|
||||
$realPath = realpath($path);
|
||||
if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) {
|
||||
throw new InvalidArgumentException('进销存月报文件不存在或不可读。');
|
||||
}
|
||||
if (strtolower((string) pathinfo($realPath, PATHINFO_EXTENSION)) !== 'xlsx') {
|
||||
throw new InvalidArgumentException('进销存权威月报导入只支持 XLSX 文件。');
|
||||
}
|
||||
|
||||
return $realPath;
|
||||
}
|
||||
|
||||
private function assertSchemaReady(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
foreach ([self::STATE_TABLE, self::ROW_TABLE] as $table) {
|
||||
if (! $schema->hasTable($table)) {
|
||||
throw new RuntimeException('进销存权威月报表尚未完成迁移:' . $table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{source_rows:int,source_month:string} */
|
||||
private function readAndStore(string $path, string $generation, mixed $importedAt): array
|
||||
{
|
||||
$archive = new ZipArchive();
|
||||
if ($archive->open($path) !== true) {
|
||||
throw new InvalidArgumentException('无法打开进销存月报 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 InvalidArgumentException('无法读取进销存月报 XLSX 工作表。');
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
$seen = [];
|
||||
$sourceMonth = null;
|
||||
$rowCount = 0;
|
||||
$batch = [];
|
||||
|
||||
try {
|
||||
while ($reader->read()) {
|
||||
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowNumber = (int) ($reader->getAttribute('r') ?: 0);
|
||||
$values = $this->row($reader->readOuterXml(), $sharedStrings);
|
||||
if ($columns === []) {
|
||||
$columns = $this->headerColumns($values);
|
||||
if ($columns === [] && $rowNumber < 20) {
|
||||
continue;
|
||||
}
|
||||
if ($columns === []) {
|
||||
throw new InvalidArgumentException('前 20 行中未找到进销存月报标准表头。');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($this->emptyRow($values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$month = $this->month($values[$columns['月份']] ?? null, $rowNumber);
|
||||
if ($sourceMonth !== null && $sourceMonth !== $month) {
|
||||
throw new InvalidArgumentException('进销存权威月报必须只包含一个月份。');
|
||||
}
|
||||
$sourceMonth = $month;
|
||||
|
||||
$owner = $this->requiredText($values[$columns['货主']] ?? null, $rowNumber, '货主');
|
||||
$warehouseProvider = $this->requiredText(
|
||||
$values[$columns['仓储方']] ?? null,
|
||||
$rowNumber,
|
||||
'仓储方',
|
||||
);
|
||||
$warehouse = $this->requiredText($values[$columns['仓库']] ?? null, $rowNumber, '仓库');
|
||||
$productCode = ProductCatalogAuthoritativeExportImporter::normalizeSkuId(
|
||||
(string) ($values[$columns['商品编码']] ?? ''),
|
||||
);
|
||||
if ($productCode === '') {
|
||||
throw new InvalidArgumentException("第 {$rowNumber} 行“商品编码”不能为空。");
|
||||
}
|
||||
|
||||
$rowKey = hash('sha256', implode("\0", [
|
||||
$month,
|
||||
$warehouseProvider,
|
||||
$warehouse,
|
||||
$productCode,
|
||||
]));
|
||||
if (isset($seen[$rowKey])) {
|
||||
throw new InvalidArgumentException(
|
||||
"第 {$rowNumber} 行与第 {$seen[$rowKey]} 行的月份、仓储方、仓库和商品编码重复。",
|
||||
);
|
||||
}
|
||||
$seen[$rowKey] = $rowNumber;
|
||||
|
||||
$opening = $this->number($values[$columns['期初数量']] ?? null, $rowNumber, '期初数量');
|
||||
$closing = $this->number($values[$columns['期末数量']] ?? null, $rowNumber, '期末数量');
|
||||
$movement = 0.0;
|
||||
$movementValues = [];
|
||||
foreach (self::MOVEMENT_HEADERS as $header) {
|
||||
$movementValues[$header] = $this->number(
|
||||
$values[$columns[$header]] ?? null,
|
||||
$rowNumber,
|
||||
$header,
|
||||
);
|
||||
$movement += $movementValues[$header];
|
||||
}
|
||||
if (abs(($opening + $movement) - $closing) > 0.0001) {
|
||||
throw new InvalidArgumentException(
|
||||
"第 {$rowNumber} 行期初数量加进出数量不等于期末数量。",
|
||||
);
|
||||
}
|
||||
|
||||
$batch[] = [
|
||||
'generation' => $generation,
|
||||
'row_key' => $rowKey,
|
||||
'report_month' => $month,
|
||||
'owner' => mb_substr($owner, 0, 191),
|
||||
'warehouse_provider' => mb_substr($warehouseProvider, 0, 191),
|
||||
'warehouse' => mb_substr($warehouse, 0, 191),
|
||||
'product_code' => mb_substr($productCode, 0, 191),
|
||||
'category' => $this->nullableText($values[$columns['分类']] ?? null, 191),
|
||||
'opening_quantity' => $opening,
|
||||
'closing_quantity' => $closing,
|
||||
'processing_in' => $movementValues['加工进仓'],
|
||||
'processing_out' => $movementValues['加工出仓'],
|
||||
'purchase_in' => $movementValues['采购进仓'],
|
||||
'purchase_return' => $movementValues['采购退货'],
|
||||
'other_in' => $movementValues['其它进仓'],
|
||||
'other_return' => $movementValues['其它退货'],
|
||||
'sales_out' => $movementValues['销售出仓'],
|
||||
'sales_return' => $movementValues['销售退货'],
|
||||
'stocktake' => $movementValues['盘点'],
|
||||
'transfer_out' => $movementValues['调拨出'],
|
||||
'transfer_in' => $movementValues['调拨入'],
|
||||
'opening' => $movementValues['期初'],
|
||||
'other_out' => $movementValues['其它出库'],
|
||||
'imported_at' => $importedAt,
|
||||
];
|
||||
$rowCount++;
|
||||
|
||||
if (count($batch) >= 500) {
|
||||
$this->connection->table(self::ROW_TABLE)->insert($batch);
|
||||
$batch = [];
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$reader->close();
|
||||
}
|
||||
|
||||
if ($batch !== []) {
|
||||
$this->connection->table(self::ROW_TABLE)->insert($batch);
|
||||
}
|
||||
if ($columns === [] || $rowCount === 0 || $sourceMonth === null) {
|
||||
throw new InvalidArgumentException('进销存权威月报中没有可用明细。');
|
||||
}
|
||||
|
||||
return ['source_rows' => $rowCount, 'source_month' => $sourceMonth];
|
||||
}
|
||||
|
||||
/** @return array<int,string> */
|
||||
private function row(string $xml, array $sharedStrings): array
|
||||
{
|
||||
$row = simplexml_load_string($xml);
|
||||
if (! $row instanceof SimpleXMLElement) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = [];
|
||||
foreach ($row->xpath('./*[local-name()="c"]') ?: [] as $cell) {
|
||||
$reference = (string) ($cell['r'] ?? '');
|
||||
if (preg_match('/^([A-Z]+)\d+$/i', $reference, $matches) !== 1) {
|
||||
continue;
|
||||
}
|
||||
$column = Coordinate::columnIndexFromString(strtoupper($matches[1]));
|
||||
$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;
|
||||
}
|
||||
$values[$column] = $value;
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/** @param array<int,string> $values @return array<string,int> */
|
||||
private function headerColumns(array $values): array
|
||||
{
|
||||
$lookup = [];
|
||||
foreach ($values as $column => $value) {
|
||||
$heading = trim((string) $value);
|
||||
if ($heading !== '') {
|
||||
$lookup[$heading] = $column;
|
||||
}
|
||||
}
|
||||
foreach (self::REQUIRED_HEADERS as $header) {
|
||||
if (! isset($lookup[$header])) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return array_intersect_key($lookup, array_flip(self::REQUIRED_HEADERS));
|
||||
}
|
||||
|
||||
/** @param array<int,string> $values */
|
||||
private function emptyRow(array $values): bool
|
||||
{
|
||||
foreach ($values as $value) {
|
||||
if (trim((string) $value) !== '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function month(mixed $value, int $rowNumber): string
|
||||
{
|
||||
try {
|
||||
$date = is_numeric($value)
|
||||
? Carbon::instance(ExcelDate::excelToDateTimeObject((float) $value))
|
||||
: Carbon::parse(str_replace('/', '-', trim((string) $value)));
|
||||
} catch (Throwable) {
|
||||
throw new InvalidArgumentException("第 {$rowNumber} 行“月份”不是有效日期。");
|
||||
}
|
||||
|
||||
return $date->startOfMonth()->toDateString();
|
||||
}
|
||||
|
||||
private function requiredText(mixed $value, int $rowNumber, string $label): string
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
if ($text === '') {
|
||||
throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”不能为空。");
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
private function nullableText(mixed $value, int $length): ?string
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
|
||||
return $text === '' ? null : mb_substr($text, 0, $length);
|
||||
}
|
||||
|
||||
private function number(mixed $value, int $rowNumber, string $label): float
|
||||
{
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return 0.0;
|
||||
}
|
||||
$normalized = str_replace([',', ','], '', trim((string) $value));
|
||||
if (! is_numeric($normalized)) {
|
||||
throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”不是有效数字。");
|
||||
}
|
||||
|
||||
return round((float) $normalized, 4);
|
||||
}
|
||||
|
||||
/** @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 InvalidArgumentException('无法读取 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 InvalidArgumentException('XLSX 文件中没有工作表。');
|
||||
}
|
||||
|
||||
return $worksheets[0];
|
||||
}
|
||||
|
||||
private function zipUri(string $path, string $entry): string
|
||||
{
|
||||
return 'zip://' . str_replace('\\', '/', $path) . '#' . $entry;
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,14 @@ class ThirdPartyWarehouseStorageSynchronizer
|
||||
|
||||
private const INVENTORY_TABLE = 'third_party_warehouse_inventory_snapshots';
|
||||
|
||||
private const SUBWAREHOUSE_INVENTORY_TABLE = 'third_party_warehouse_inventory_subwarehouse_snapshots';
|
||||
|
||||
/** @var array<int,string> */
|
||||
private const SUBWAREHOUSE_QUANTITY_FIELDS = [
|
||||
1 => 'qty', 2 => 'return_qty', 3 => 'in_qty', 4 => 'defective_qty',
|
||||
6 => 'customize_qty_1', 7 => 'customize_qty_2', 8 => 'customize_qty_3',
|
||||
];
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
private readonly Repository $cache;
|
||||
@ -263,12 +271,7 @@ class ThirdPartyWarehouseStorageSynchronizer
|
||||
$changedHeaders,
|
||||
))));
|
||||
$latestHeaders = $this->upstream->latestHeadersForIoIds($changedIoIds, $throughAt);
|
||||
$targetHeaders = array_values(array_filter(
|
||||
$latestHeaders,
|
||||
static fn (array $row): bool =>
|
||||
(int) ($row['wms_co_id'] ?? 0)
|
||||
=== ThirdPartyWarehouseStorageUpstreamRepository::WAREHOUSE_ID,
|
||||
));
|
||||
$targetHeaders = $latestHeaders;
|
||||
$items = $this->upstream->itemsForIoIds(array_map(
|
||||
static fn (array $row): mixed => $row['io_id'] ?? null,
|
||||
$targetHeaders,
|
||||
@ -395,7 +398,7 @@ class ThirdPartyWarehouseStorageSynchronizer
|
||||
self::HEADER_TABLE,
|
||||
(string) ($state['active_header_generation'] ?? ''),
|
||||
(string) $values['pending_header_generation'],
|
||||
['source_key', 'id', 'io_id', 'wms_co_id', 'status', 'io_type', 'occurred_at', 'source_modified_at', 'row_hash', 'synced_at'],
|
||||
['source_key', 'id', 'io_id', 'wms_co_id', 'wh_id', 'status', 'io_type', 'occurred_at', 'source_modified_at', 'row_hash', 'synced_at'],
|
||||
);
|
||||
$this->cloneActiveGeneration(
|
||||
self::ITEM_TABLE,
|
||||
@ -615,9 +618,14 @@ class ThirdPartyWarehouseStorageSynchronizer
|
||||
fn (array $row): ?array => $this->mapInventory($row, $generation, $now),
|
||||
$rows,
|
||||
)));
|
||||
$subwarehouseMapped = [];
|
||||
foreach ($rows as $row) {
|
||||
array_push($subwarehouseMapped, ...$this->mapSubwarehouseInventory($row, $generation, $now));
|
||||
}
|
||||
|
||||
$this->connection->transaction(function () use (
|
||||
$mapped,
|
||||
$subwarehouseMapped,
|
||||
$generation,
|
||||
$cursor,
|
||||
$now,
|
||||
@ -625,6 +633,11 @@ class ThirdPartyWarehouseStorageSynchronizer
|
||||
): void {
|
||||
$this->assertCurrentRunToken($runToken);
|
||||
$this->upsertRows(self::INVENTORY_TABLE, $mapped, ['generation', 'id']);
|
||||
$this->upsertRows(
|
||||
self::SUBWAREHOUSE_INVENTORY_TABLE,
|
||||
$subwarehouseMapped,
|
||||
['generation', 'id', 'wh_id'],
|
||||
);
|
||||
$this->updateStateForToken([
|
||||
'inventory_cursor' => $cursor,
|
||||
'updated_at' => $now,
|
||||
@ -692,6 +705,7 @@ class ThirdPartyWarehouseStorageSynchronizer
|
||||
);
|
||||
$values['header_modified_cursor_id'] = 0;
|
||||
if ($mode === 'full') {
|
||||
$values['scope_version'] = 2;
|
||||
$values['initial_completed_at'] = $now;
|
||||
$values['last_full_succeeded_at'] = $now;
|
||||
}
|
||||
@ -991,6 +1005,7 @@ class ThirdPartyWarehouseStorageSynchronizer
|
||||
[self::HEADER_TABLE, 'header_generation', (string) $release->header_generation],
|
||||
[self::ITEM_TABLE, 'item_generation', (string) $release->item_generation],
|
||||
[self::INVENTORY_TABLE, 'inventory_generation', (string) $release->inventory_generation],
|
||||
[self::SUBWAREHOUSE_INVENTORY_TABLE, 'inventory_generation', (string) $release->inventory_generation],
|
||||
] as [$table, $column, $generation]) {
|
||||
if ($generation === '' || in_array($generation, $protected, true)) {
|
||||
continue;
|
||||
@ -1036,6 +1051,7 @@ class ThirdPartyWarehouseStorageSynchronizer
|
||||
'id' => $id,
|
||||
'io_id' => $ioId,
|
||||
'wms_co_id' => $warehouseId,
|
||||
'wh_id' => max((int) ($row['wh_id'] ?? 0), 0),
|
||||
'status' => $this->text($row['status'] ?? null, 32),
|
||||
'io_type' => $this->text($row['io_type'] ?? null, 64),
|
||||
'occurred_at' => $this->dateTime($row['occurred_at'] ?? null),
|
||||
@ -1113,6 +1129,40 @@ class ThirdPartyWarehouseStorageSynchronizer
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function mapSubwarehouseInventory(array $row, string $generation, mixed $now): array
|
||||
{
|
||||
$base = $this->mapInventory($row, $generation, $now);
|
||||
if ($base === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach (self::SUBWAREHOUSE_QUANTITY_FIELDS as $warehouseId => $field) {
|
||||
$quantity = (float) ($row[$field] ?? 0);
|
||||
if ($quantity == 0.0) {
|
||||
continue;
|
||||
}
|
||||
$values = [
|
||||
'generation' => $generation,
|
||||
'source_key' => $base['source_key'],
|
||||
'id' => $base['id'],
|
||||
'wms_co_id' => $base['wms_co_id'],
|
||||
'wh_id' => $warehouseId,
|
||||
'sku_code' => $base['sku_code'],
|
||||
'qty' => $quantity,
|
||||
'source_modified_at' => $base['source_modified_at'],
|
||||
'captured_at' => $base['captured_at'],
|
||||
];
|
||||
$rows[] = $values + [
|
||||
'row_hash' => $this->rowHash($values),
|
||||
'synced_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @param list<string> $uniqueBy
|
||||
@ -1226,6 +1276,7 @@ class ThirdPartyWarehouseStorageSynchronizer
|
||||
self::HEADER_TABLE,
|
||||
self::ITEM_TABLE,
|
||||
self::INVENTORY_TABLE,
|
||||
self::SUBWAREHOUSE_INVENTORY_TABLE,
|
||||
] as $table) {
|
||||
if (! $schema->hasTable($table)) {
|
||||
throw new RuntimeException('third_party_warehouse_mirror_schema_missing:' . $table);
|
||||
@ -1275,6 +1326,7 @@ class ThirdPartyWarehouseStorageSynchronizer
|
||||
'inventory_row_count',
|
||||
'initial_completed_at',
|
||||
'last_full_succeeded_at',
|
||||
'scope_version',
|
||||
] as $column) {
|
||||
if (! $schema->hasColumn(self::STATE_TABLE, $column)) {
|
||||
throw new RuntimeException('third_party_warehouse_mirror_state_missing:' . $column);
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
|
||||
<form class="tw-filter" @submit.prevent="applyFilters">
|
||||
<label>原始店铺<input v-model.trim="filters.store_name" placeholder="如 天猫-亿家达旗舰店" /></label>
|
||||
<label>平台渠道<input v-model.trim="filters.alipay_account" placeholder="如 CommonBillTGCPay" /></label>
|
||||
<label>数据源渠道<input v-model.trim="filters.source_channel" placeholder="如 alipayCSV" /></label>
|
||||
<label>账单项目<input v-model.trim="filters.bill_item" placeholder="如 交易佣金" /></label>
|
||||
<label>收支类型<select v-model="filters.business_type"><option value="">全部类型</option><option>收入</option><option>支出</option></select></label>
|
||||
<label class="tw-date">发生时间<span><input v-model="filters.start" type="date" aria-label="开始日期" /><b>至</b><input v-model="filters.end" type="date" aria-label="结束日期" /></span></label>
|
||||
@ -71,8 +71,8 @@ import TableColumnSettings from './TableColumnSettings.vue';
|
||||
|
||||
const COLUMNS = [
|
||||
['记录编码', 'record_code', 'num', '基础信息'], ['原始店铺', 'store_name', 'text', '基础信息'], ['自定义备注', 'custom_remark', 'text', '基础信息'], ['账单项目', 'bill_item', 'tag', '基础信息'],
|
||||
['平台渠道', 'alipay_account', 'num', '账户与交易'], ['账务订单号', 'account_order_no', 'num', '账户与交易'], ['业务流水号', 'business_serial_no', 'num', '账户与交易'], ['商户订单号', 'merchant_order_no', 'num', '账户与交易'], ['业务基础订单号', 'business_base_order_no', 'num', '账户与交易'], ['商品编码', 'goods_name', 'num', '账户与交易'], ['对方账号', 'counterparty_account', 'text', '账户与交易'], ['发生时间', 'occurred_at', 'date', '账户与交易'],
|
||||
['收入', 'income', 'money_in', '金额(元)'], ['支出', 'expense', 'money_out', '金额(元)'], ['收支净额', 'balance', 'money', '金额(元)'],
|
||||
['支付宝账号', 'alipay_account', 'num', '账户与交易'], ['账务订单号', 'account_order_no', 'num', '账户与交易'], ['业务流水号', 'business_serial_no', 'num', '账户与交易'], ['商户订单号', 'merchant_order_no', 'num', '账户与交易'], ['业务基础订单号', 'business_base_order_no', 'num', '账户与交易'], ['商品名称', 'goods_name', 'text', '账户与交易'], ['对方账号', 'counterparty_account', 'text', '账户与交易'], ['发生时间', 'occurred_at', 'date', '账户与交易'],
|
||||
['收入', 'income', 'money_in', '金额(元)'], ['支出', 'expense', 'money_out', '金额(元)'], ['余额', 'balance', 'money', '金额(元)'],
|
||||
['业务类型', 'business_type', 'tag', '业务关联'], ['备注', 'remark', 'text', '业务关联'], ['线上单号', 'online_order_no', 'num', '业务关联'], ['线上单号对应实际店铺', 'online_real_store', 'text', '业务关联'], ['业务描述', 'business_description', 'text', '业务关联'], ['业务账单来源', 'business_bill_source', 'text', '业务关联'],
|
||||
['业务订单号', 'business_order_no', 'num', '维护信息'], ['创建时间', 'created_at', 'date', '维护信息'], ['修改时间', 'updated_at', 'date', '维护信息'], ['修改人', 'modifier', 'text', '维护信息'],
|
||||
].map(([label, field, kind, group]) => ({ key: field, label, field, kind, group }));
|
||||
@ -81,17 +81,17 @@ const SORTABLE = ['occurred_at', 'income', 'expense', 'balance', 'record_code'];
|
||||
export default {
|
||||
name: 'TaobaoAlipayBills', components: { TableColumnSettings },
|
||||
props: { apiBase: { type: String, required: true }, exportBase: { type: String, required: true }, userKey: { type: [String, Number], required: true } },
|
||||
data() { return { loading: false, error: '', rows: [], configuredColumns: [], summary: { income: 0, expense: 0, balance: 0, count: 0 }, total: 0, currentPage: 1, lastPage: 1, perPage: 20, filters: { store_name: '', alipay_account: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }, sort: { key: 'occurred_at', dir: 'desc' }, exportMode: 'filtered-visible' }; },
|
||||
data() { return { loading: false, error: '', rows: [], configuredColumns: [], summary: { income: 0, expense: 0, balance: 0, count: 0 }, total: 0, currentPage: 1, lastPage: 1, perPage: 20, filters: { store_name: '', source_channel: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }, sort: { key: 'occurred_at', dir: 'desc' }, exportMode: 'filtered-visible' }; },
|
||||
computed: {
|
||||
defaultColumns() { return COLUMNS; }, visibleColumns() { return this.configuredColumns.filter((column) => column.visible); },
|
||||
visibleGroups() { return this.visibleColumns.reduce((groups, column) => { const last = groups[groups.length - 1]; if (last && last.label === column.group) last.span += 1; else groups.push({ label: column.group, span: 1 }); return groups; }, []); },
|
||||
stats() { return [{ label: '收入合计', value: this.money(this.summary.income), note: `${this.summary.count} 笔交易`, className: 'is-income' }, { label: '支出合计', value: this.money(this.summary.expense), note: '按当前筛选', className: 'is-expense' }, { label: '收支净额', value: this.money(this.summary.balance), note: '收入合计 - 支出合计', className: '' }, { label: '交易笔数', value: this.summary.count, note: '当前筛选结果', className: '' }]; },
|
||||
stats() { return [{ label: '收入合计', value: this.money(this.summary.income), note: `${this.summary.count} 笔交易`, className: 'is-income' }, { label: '支出合计', value: this.money(this.summary.expense), note: '按当前筛选', className: 'is-expense' }, { label: '收支差额', value: this.money(this.summary.balance), note: '收入合计 - 支出合计', className: '' }, { label: '交易笔数', value: this.summary.count, note: '当前筛选结果', className: '' }]; },
|
||||
rangeStart() { return this.total === 0 ? 0 : (this.currentPage - 1) * this.perPage + 1; }, rangeEnd() { return Math.min(this.currentPage * this.perPage, this.total); },
|
||||
},
|
||||
mounted() { this.fetch(); },
|
||||
methods: {
|
||||
async fetch() { this.loading = true; this.error = ''; try { const response = await window.axios.get(this.apiBase, { params: { ...this.filters, sort: this.sort.key, dir: this.sort.dir, limit: this.perPage, page: this.currentPage } }); const body = response.data || {}; this.rows = body.data || []; this.total = body.total || 0; this.currentPage = body.current_page || 1; this.lastPage = body.last_page || 1; this.perPage = body.per_page || this.perPage; this.summary = body.summary || { income: 0, expense: 0, balance: 0, count: 0 }; } catch (error) { this.rows = []; this.total = 0; this.summary = { income: 0, expense: 0, balance: 0, count: 0 }; this.error = (error.response && error.response.data && error.response.data.message) || '淘系支付宝账单暂时无法读取,请稍后重试。'; } finally { this.loading = false; } },
|
||||
applyFilters() { this.currentPage = 1; this.fetch(); }, resetFilters() { this.filters = { store_name: '', alipay_account: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }; this.applyFilters(); },
|
||||
applyFilters() { this.currentPage = 1; this.fetch(); }, resetFilters() { this.filters = { store_name: '', source_channel: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }; this.applyFilters(); },
|
||||
changePage(page) { this.currentPage = page; this.fetch(); this.$nextTick(() => { if (this.$refs.scroll) this.$refs.scroll.scrollTop = 0; }); },
|
||||
maybeSort(column) { if (!this.sortableField(column.field)) return; this.sort.dir = this.sort.key === column.field && this.sort.dir === 'desc' ? 'asc' : 'desc'; this.sort.key = column.field; this.applyFilters(); }, sortableField(field) { return SORTABLE.indexOf(field) !== -1; }, sortMark(field) { return this.sort.key === field ? (this.sort.dir === 'asc' ? '↑' : '↓') : '↕'; },
|
||||
rawValue(row, column) { const value = row[column.field]; return value === null || value === undefined ? '' : value; },
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1395,7 +1395,12 @@
|
||||
<input class="agreement-control" id="agreement-deadline" name="signing_deadline" type="date" min="{{ now()->toDateString() }}" value="{{ now()->addDays(7)->toDateString() }}" required>
|
||||
</div>
|
||||
<div class="agreement-field">
|
||||
<label for="agreement-esign-account">对方E签宝账号</label>
|
||||
<label for="agreement-signature-date">签署日期 <span class="agreement-required" aria-hidden="true">*</span></label>
|
||||
<input class="agreement-control" id="agreement-signature-date" name="signature_date" type="date" value="{{ old('signature_date', now()->toDateString()) }}" required>
|
||||
<p class="agreement-help">该日期会直接写入待签文件,并随原文一起推送到 E 签宝。</p>
|
||||
</div>
|
||||
<div class="agreement-field">
|
||||
<label for="agreement-esign-account"><span data-primary-esign-label>对方 E 签宝账号</span></label>
|
||||
<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>
|
||||
@ -1417,7 +1422,7 @@
|
||||
<p class="agreement-help">确认单绑定已审核报价版本,生成后保存该版本快照;变价请生成新版本并重新签署。</p>
|
||||
</div>
|
||||
<div class="agreement-field" data-secondary-signer-fields hidden>
|
||||
<label for="agreement-secondary-esign-account">第二签署方E签宝账号 <span class="agreement-required" aria-hidden="true">*</span></label>
|
||||
<label for="agreement-secondary-esign-account"><span data-secondary-esign-label>第二签署方 E 签宝账号</span> <span class="agreement-required" aria-hidden="true">*</span></label>
|
||||
<input class="agreement-control" id="agreement-secondary-esign-account" name="secondary_esign_account" type="text" placeholder="填写另一签署方的手机号或邮箱" autocomplete="email" disabled>
|
||||
<p class="agreement-help">主体变更、合作合同和授权委托书需要双方分别实名签署;合作合同统一使用我方账号 {{ $agreementCooperationSecondaryEsignAccount }}。</p>
|
||||
</div>
|
||||
@ -1500,10 +1505,6 @@
|
||||
<label for="agreement-changed-company-start-date">新公司启用日期 <span class="agreement-required" aria-hidden="true">*</span></label>
|
||||
<input class="agreement-control" id="agreement-changed-company-start-date" name="changed_company_start_date" type="date" value="{{ now()->toDateString() }}" required>
|
||||
</div>
|
||||
<div class="agreement-field">
|
||||
<label for="agreement-entity-proof-date">证明日期 <span class="agreement-required" aria-hidden="true">*</span></label>
|
||||
<input class="agreement-control" id="agreement-entity-proof-date" name="entity_proof_date" type="date" value="{{ now()->toDateString() }}" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agreement-form-grid" data-template-fields="shared-contract" hidden>
|
||||
@ -1650,7 +1651,7 @@
|
||||
<p>以上收款账户的变更,以我司的盖章为有效。</p>
|
||||
<div class="agreement-preview-signature">
|
||||
<p><span data-preview-bind="proofSigner">待填写</span>:</p>
|
||||
<p>日期:<span data-preview-bind="signatureDate">签署后生成</span></p>
|
||||
<p>日期:<span data-preview-bind="signatureDate">{{ now()->format('Y年n月j日') }}</span></p>
|
||||
</div>
|
||||
<p class="agreement-preview-page-number">第 1 页 / 共 1 页</p>
|
||||
</article>
|
||||
@ -1668,7 +1669,7 @@
|
||||
<p>以上收款账户的变更,以我司的盖章为有效。</p>
|
||||
<div class="agreement-preview-signature">
|
||||
<p><span data-preview-bind="changeSigner">待填写</span>:</p>
|
||||
<p>日期:<span data-preview-bind="signatureDate">签署后生成</span></p>
|
||||
<p>日期:<span data-preview-bind="signatureDate">{{ now()->format('Y年n月j日') }}</span></p>
|
||||
</div>
|
||||
<p class="agreement-preview-page-number">第 1 页 / 共 1 页</p>
|
||||
</article>
|
||||
@ -1682,7 +1683,7 @@
|
||||
<div class="agreement-preview-signature">
|
||||
<p>变更前公司法人签字盖公章:</p>
|
||||
<p>变更后公司法人签字盖公章:</p>
|
||||
<p><span data-preview-bind="entityProofDate">待填写</span></p>
|
||||
<p>日期:<span data-preview-bind="signatureDate">{{ now()->format('Y年n月j日') }}</span></p>
|
||||
</div>
|
||||
<p class="agreement-preview-page-number">第 1 页 / 共 1 页</p>
|
||||
</article>
|
||||
@ -1694,7 +1695,7 @@
|
||||
<p data-preview-shared-note hidden>备注:<span class="agreement-preview-value" data-preview-bind="sharedNote">待填写</span></p>
|
||||
<div class="agreement-preview-signature">
|
||||
<p>法人签字并公司盖章:</p>
|
||||
<p>日期:签署后生成</p>
|
||||
<p>日期:<span data-preview-bind="signatureDate">{{ now()->format('Y年n月j日') }}</span></p>
|
||||
</div>
|
||||
<p class="agreement-preview-page-number">第 1 页 / 共 1 页</p>
|
||||
</article>
|
||||
@ -1726,7 +1727,7 @@
|
||||
<p>受托方:<span data-preview-bind="trusteeName">待填写</span></p>
|
||||
</div>
|
||||
<div class="agreement-preview-signature">
|
||||
<p>日期:<span data-preview-bind="signatureDate">签署后生成</span></p>
|
||||
<p>日期:<span data-preview-bind="signatureDate">{{ now()->format('Y年n月j日') }}</span></p>
|
||||
</div>
|
||||
<p class="agreement-preview-page-number">第 1 页 / 共 1 页</p>
|
||||
</article>
|
||||
@ -1746,7 +1747,7 @@
|
||||
</div>
|
||||
<p>双方确认以下报价版本作为快递服务结算依据。报价版本一经签署即形成独立留档,后续变价须生成新版本并重新签署。</p>
|
||||
<div class="agreement-preview-quote-matrix" data-preview-bind="quoteMatrix">请选择报价版本后查看报价明细</div>
|
||||
<div class="agreement-preview-signature"><p>乙方(快递方)签章:</p><p>日期:签署后生成</p></div>
|
||||
<div class="agreement-preview-signature"><p>乙方(快递方)签章:</p><p>日期:<span data-preview-bind="signatureDate">{{ now()->format('Y年n月j日') }}</span></p></div>
|
||||
<p class="agreement-preview-page-number">报价确认单 · 快递方单方签署</p>
|
||||
</article>
|
||||
</div>
|
||||
@ -2219,6 +2220,24 @@
|
||||
});
|
||||
}
|
||||
|
||||
function refreshSignerAccountLabels(templateType) {
|
||||
var labels = {
|
||||
primary: '对方 E 签宝账号',
|
||||
secondary: '第二签署方 E 签宝账号'
|
||||
};
|
||||
|
||||
if (templateType === 'cooperation-contract') {
|
||||
labels.primary = '快递方 E 签宝账号';
|
||||
labels.secondary = '我方 E 签宝账号';
|
||||
} else if (templateType === 'authorization') {
|
||||
labels.primary = '委托方 E 签宝账号';
|
||||
labels.secondary = '受托方 E 签宝账号';
|
||||
}
|
||||
|
||||
setText('[data-primary-esign-label]', labels.primary);
|
||||
setText('[data-secondary-esign-label]', labels.secondary);
|
||||
}
|
||||
|
||||
function formatDateField(selector, fallback) {
|
||||
var value = fieldValue(selector, '');
|
||||
var parts = value.split('-');
|
||||
@ -2381,6 +2400,7 @@
|
||||
operatorRequiredMark.hidden = !accountOperatorRequired;
|
||||
}
|
||||
activateTemplate(templateType);
|
||||
refreshSignerAccountLabels(templateType);
|
||||
var secondary = root.querySelector('[data-secondary-signer-fields]');
|
||||
if (secondary) {
|
||||
var needsSecondary = templateType === 'entity-change' || templateType === 'cooperation-contract' || templateType === 'authorization';
|
||||
@ -2412,7 +2432,7 @@
|
||||
setText('[data-preview-template-code]', documentOption.dataset.code);
|
||||
setPreviewValue('company', companyName);
|
||||
setPreviewValue('courier', courier);
|
||||
setPreviewValue('signatureDate', '签署后生成');
|
||||
setPreviewValue('signatureDate', formatDateField('[name="signature_date"]', '待填写'));
|
||||
|
||||
if (templateType === 'account-proof') {
|
||||
setPreviewValue('proofEffectiveDate', formatDateField('[name="account_proof_effective_date"]', '待填写'));
|
||||
@ -2441,7 +2461,6 @@
|
||||
setPreviewValue('entityChangeDate', formatDateField('[name="entity_change_date"]', '待填写'));
|
||||
setPreviewValue('changedCompanyName', fieldValue('[name="changed_company_name"]', '待填写'));
|
||||
setPreviewValue('changedCompanyStartDate', formatDateField('[name="changed_company_start_date"]', '待填写'));
|
||||
setPreviewValue('entityProofDate', formatDateField('[name="entity_proof_date"]', '待填写'));
|
||||
}
|
||||
|
||||
if (templateType === 'shared-contract') {
|
||||
|
||||
@ -4,7 +4,10 @@
|
||||
$summary = $courierSurchargeSummary ?? [];
|
||||
$options = $courierSurchargeOptions ?? [];
|
||||
$ready = (bool) ($courierSurchargeReady ?? false);
|
||||
$dataReady = (bool) ($courierSurchargeDataReady ?? $ready);
|
||||
$auditReady = (bool) ($courierSurchargeAuditReady ?? $ready);
|
||||
$typeUsage = $courierSurchargeTypeUsage ?? [];
|
||||
$auditLogs = $courierSurchargeAuditLogs ?? collect();
|
||||
$statusClass = static fn (string $status): string => $status === '已启用' ? 'is-active' : 'is-inactive';
|
||||
$columns = [
|
||||
['key' => 'id', 'label' => '规则编号', 'fixed' => true, 'exportable' => true],
|
||||
@ -49,6 +52,13 @@
|
||||
.sm-actions { display:flex; flex-wrap:wrap; gap:.45rem; } .sm-button { display:inline-flex; align-items:center; justify-content:center; gap:.35rem; height:2.2rem; padding:0 .75rem; border:1px solid var(--sm-border); border-radius:.45rem; background:#fff; color:var(--sm-ink); font:inherit; font-weight:600; text-decoration:none; white-space:nowrap; cursor:pointer; } .sm-button .material-icons-outlined { font-size:1rem; } .sm-button--primary { border-color:var(--sm-accent); background:var(--sm-accent); color:#fff; } .sm-button--primary:hover { background:#6a5129; } .sm-button:disabled { cursor:not-allowed; opacity:.55; }
|
||||
.sm-table-wrap { max-height:min(62vh,42rem); overflow:auto; overscroll-behavior:contain; scrollbar-gutter:stable both-edges; } .sm-table { width:max-content; min-width:100%; border-collapse:separate; border-spacing:0; font-size:.75rem; } .sm-table th,.sm-table td { min-width:7.5rem; padding:.62rem .7rem; border-right:1px solid var(--sm-border); border-bottom:1px solid var(--sm-border); text-align:left; vertical-align:top; white-space:nowrap; } .sm-table th { position:sticky; top:0; z-index:2; background:var(--sm-soft); font-weight:700; } .sm-table [data-column-key="id"] { position:sticky; left:0; z-index:3; background:#fff; } .sm-table th[data-column-key="id"] { z-index:5; background:var(--sm-soft); } .sm-table [data-column-key="actions"] { position:sticky; right:0; z-index:3; background:#fff; box-shadow:-1px 0 0 var(--sm-border); } .sm-table th[data-column-key="actions"] { z-index:5; background:var(--sm-soft); } .sm-table tr:hover [data-column-key="id"],.sm-table tr:hover [data-column-key="actions"] { background:#fcfaf4; } .sm-table th[data-fixed="true"] { cursor:default; } .sm-table th[data-column-header]:not([data-fixed="true"]) { cursor:grab; } .sm-table th[data-column-header]:not([data-fixed="true"]):active { cursor:grabbing; } .sm-table th.is-dragging { opacity:.55; } .sm-table th.is-drop-target { box-shadow:inset 3px 0 0 var(--sm-accent); } .sm-table th .sm-table__header { display:inline-flex; align-items:center; gap:.35rem; } .sm-table th .sm-table__drag { color:var(--sm-muted); font-size:1rem; } .sm-table [data-column-key].is-column-hidden { display:none; } .sm-table tr:hover td { background:#fcfaf4; } .sm-table .wrap { max-width:18rem; white-space:normal; line-height:1.5; } .sm-table .actions { display:flex; gap:.35rem; } .sm-table .actions button { height:1.8rem; padding:0 .5rem; font-size:.7rem; }
|
||||
.sm-table-meta { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.6rem 1rem; padding:.65rem 1.15rem; border-bottom:1px solid var(--sm-border); background:#fffdf8; color:var(--sm-muted); font-size:.72rem; }
|
||||
.sm-audit-table-wrap { max-height:24rem; overflow:auto; overscroll-behavior:contain; scrollbar-gutter:stable both-edges; }
|
||||
.sm-audit-table { width:max-content; min-width:100%; border-collapse:separate; border-spacing:0; font-size:.75rem; }
|
||||
.sm-audit-table th,.sm-audit-table td { min-width:8rem; padding:.6rem .7rem; border-right:1px solid var(--sm-border); border-bottom:1px solid var(--sm-border); text-align:left; vertical-align:top; white-space:nowrap; }
|
||||
.sm-audit-table th { background:var(--sm-soft); font-weight:700; }
|
||||
.sm-audit-table td:nth-child(5) { min-width:24rem; white-space:normal; line-height:1.5; }
|
||||
.sm-audit-action { display:inline-flex; align-items:center; padding:.15rem .45rem; border-radius:999px; background:var(--sm-soft); color:var(--sm-accent); font-size:.7rem; font-weight:700; }
|
||||
.sm-audit-empty { padding:1.8rem 1rem; text-align:center; color:var(--sm-muted); }
|
||||
.sm-column-picker { position:relative; } .sm-column-picker__panel { position:absolute; right:0; z-index:20; display:grid; width:min(22rem,calc(100vw - 2rem)); gap:.65rem; margin-top:.35rem; padding:.8rem; border:1px solid var(--sm-border); border-radius:.55rem; background:#fff; box-shadow:0 4px 8px rgba(23,23,23,.1); } .sm-column-picker__panel[hidden] { display:none; } .sm-column-picker__intro { display:grid; gap:.2rem; color:var(--sm-muted); font-size:.7rem; line-height:1.45; } .sm-column-picker__intro strong { color:var(--sm-ink); font-size:.78rem; } .sm-visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } .sm-column-picker__list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.35rem; max-height:15rem; overflow:auto; } .sm-column-picker__option { display:flex; align-items:center; gap:.35rem; min-width:0; padding:.35rem .4rem; border:1px solid transparent; border-radius:.35rem; color:var(--sm-ink); font-size:.7rem; cursor:grab; } .sm-column-picker__option:hover,.sm-column-picker__option.is-drop-target { border-color:var(--sm-border); background:#fffdf8; } .sm-column-picker__option.is-fixed { cursor:default; } .sm-column-picker__option.is-dragging { opacity:.55; } .sm-column-picker__option input { flex:none; accent-color:var(--sm-accent); } .sm-column-picker__option .material-icons-outlined { color:var(--sm-muted); font-size:.95rem; } .sm-column-picker__foot { display:flex; justify-content:flex-end; padding-top:.35rem; border-top:1px solid var(--sm-border); }
|
||||
.sm-badge { display:inline-flex; align-items:center; padding:.15rem .5rem; border-radius:999px; font-size:.7rem; font-weight:700; } .sm-badge.is-active { background:#edf5e8; color:#53793e; } .sm-badge.is-inactive { background:#fff4df; color:#935f07; }
|
||||
.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; }
|
||||
@ -58,7 +68,7 @@
|
||||
@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; } }
|
||||
</style>
|
||||
|
||||
<div class="sm-notice" role="note"><span class="material-icons-outlined" aria-hidden="true">{{ $ready ? 'verified' : 'info' }}</span><div><strong>{{ $ready ? '正式维护数据' : '数据表尚未就绪' }}</strong> {{ $ready ? '报价规则已保存到当前公司数据表,刷新页面后仍会保留;后续结算页面将按启用中的规则计算。' : '附加费报价数据表尚未完成迁移,当前不能保存规则。请由管理员在目标数据库完成迁移后再使用本页。' }}</div></div>
|
||||
<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>
|
||||
|
||||
<div class="sm-summary" aria-label="报价维护概览">
|
||||
<div class="sm-summary__intro"><span class="sm-summary__icon material-icons-outlined" aria-hidden="true">local_shipping</span><div><div class="sm-summary__title">快递附加费报价</div><div class="sm-summary__subtitle">统一维护各快递的附加费结算规则</div></div></div>
|
||||
@ -110,6 +120,32 @@
|
||||
<div class="sm-rule-note"><strong>维护建议:</strong>固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。当前筛选结果可导出为 CSV;本页暂未启用批量导入,避免在未确认字段规则前覆盖维护数据。</div>
|
||||
</div>
|
||||
|
||||
<div class="sm-card" aria-label="附加费维护操作日志">
|
||||
<div class="sm-card__head"><div class="sm-card__title"><span class="material-icons-outlined" aria-hidden="true">history</span>操作日志</div><span class="sm-card__hint">记录当前公司最近 50 条新增、更改、启用和停用操作</span></div>
|
||||
@if (!$auditReady)
|
||||
<div class="sm-audit-empty">操作日志表尚未就绪,完成迁移后将从首次新操作开始记录。</div>
|
||||
@elseif ($auditLogs->isEmpty())
|
||||
<div class="sm-audit-empty">暂无操作日志。新增、编辑或切换报价规则/附加费类型状态后,记录会显示在这里。</div>
|
||||
@else
|
||||
<div class="sm-audit-table-wrap">
|
||||
<table class="sm-audit-table">
|
||||
<thead><tr><th scope="col">操作时间</th><th scope="col">操作人</th><th scope="col">动作</th><th scope="col">对象</th><th scope="col">变更内容</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach ($auditLogs as $log)
|
||||
<tr>
|
||||
<td>{{ $log['occurred_at'] }}</td>
|
||||
<td>{{ $log['actor_name'] }}</td>
|
||||
<td><span class="sm-audit-action">{{ $log['action_label'] }}</span></td>
|
||||
<td>{{ $log['target_type_label'] }} · {{ $log['target_label'] }}</td>
|
||||
<td>{{ $log['changes_label'] }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</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>
|
||||
|
||||
@ -72,7 +72,7 @@
|
||||
<div class="quote-review__body">
|
||||
@if(!$quoteSignature)
|
||||
<div class="quote-review__hint">该历史报价版本尚未生成签署原文。</div>
|
||||
@if($canManageSignature)<form method="POST" action="{{ route('business.courier-quote-signatures.prepare',$change->version) }}" style="margin-top:12px">@csrf<button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">picture_as_pdf</span><span>生成签署 PDF</span></button></form>@endif
|
||||
@if($canManageSignature)<form class="quote-review__form" method="POST" action="{{ route('business.courier-quote-signatures.prepare',$change->version) }}">@csrf<div class="quote-review__form-grid"><div class="quote-review__field"><label for="prepare_signature_date">签署日期</label><input class="quote-review__control" id="prepare_signature_date" name="signature_date" type="date" value="{{ old('signature_date',now()->toDateString()) }}" required></div></div><div class="quote-review__form-actions"><span class="quote-review__hint">日期将直接写入待签原文。</span><button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">picture_as_pdf</span><span>生成签署 PDF</span></button></div></form>@endif
|
||||
@else
|
||||
<dl class="quote-review__signature-meta">
|
||||
<div><dt>签署状态</dt><dd><span class="quote-review__status">{{ $quoteSignature->statusLabel() }}</span></dd></div>
|
||||
@ -97,6 +97,7 @@
|
||||
<div class="quote-review__field"><label for="organization_name">签署企业全称</label><input class="quote-review__control" id="organization_name" name="organization_name" value="{{ old('organization_name',$quoteSignature->organization_name) }}" maxlength="255" required></div>
|
||||
<div class="quote-review__field"><label for="operator_name">经办人姓名</label><input class="quote-review__control" id="operator_name" name="operator_name" value="{{ old('operator_name',$quoteSignature->operator_name) }}" maxlength="100" autocomplete="name" required></div>
|
||||
<div class="quote-review__field"><label for="operator_account">手机号或邮箱</label><input class="quote-review__control" id="operator_account" name="operator_account" value="{{ old('operator_account',$quoteSignature->operator_account) }}" maxlength="255" autocomplete="email" required></div>
|
||||
<div class="quote-review__field"><label for="signature_date">签署日期</label><input class="quote-review__control" id="signature_date" name="signature_date" type="date" value="{{ old('signature_date',now()->toDateString()) }}" required></div>
|
||||
<div class="quote-review__field"><label for="expires_at">签署截止时间</label><input class="quote-review__control" id="expires_at" name="expires_at" type="datetime-local" value="{{ old('expires_at',now()->addDays(7)->format('Y-m-d\TH:i')) }}" required></div>
|
||||
</div>
|
||||
<div class="quote-review__form-actions">
|
||||
|
||||
@ -1,26 +1,54 @@
|
||||
@php
|
||||
$courierSummaryImportTemplate = [
|
||||
'freight-difference' => [
|
||||
'dialog_title' => '导入运费差',
|
||||
'template_name' => '运费差导入模板',
|
||||
'field_count' => 11,
|
||||
],
|
||||
'other-surcharge-summary' => [
|
||||
'dialog_title' => '导入其他附加费汇总',
|
||||
'template_name' => '其他附加费导入模板',
|
||||
'field_count' => 11,
|
||||
],
|
||||
'return-fee' => [
|
||||
'dialog_title' => '导入退件费',
|
||||
'template_name' => '退件费导入模板',
|
||||
'field_count' => 11,
|
||||
],
|
||||
][$courierSummaryPage] ?? null;
|
||||
@endphp
|
||||
|
||||
<section class="courier-summary" data-courier-payable-summary="{{ $courierSummaryPage }}" aria-label="{{ $courierSummaryTitle }}">
|
||||
<header class="courier-summary__toolbar">
|
||||
<div class="courier-summary__heading">
|
||||
<strong>{{ $courierSummaryTitle }}</strong>
|
||||
@if ($courierSummarySourceName)
|
||||
<span>{{ $courierSummarySourceName }} · 共 {{ number_format($courierSummaryRows->total()) }} 条</span>
|
||||
<span>
|
||||
{{ $courierSummarySourceName }} ·
|
||||
@if ($courierSummaryHasFilters)
|
||||
查询到 {{ number_format($courierSummaryRows->total()) }} 条 / 共 {{ number_format($courierSummarySourceTotal) }} 条
|
||||
@else
|
||||
共 {{ number_format($courierSummarySourceTotal) }} 条
|
||||
@endif
|
||||
</span>
|
||||
@else
|
||||
<span>共 0 条</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="courier-summary__actions">
|
||||
<form method="POST" enctype="multipart/form-data" action="{{ route('business.courier-payable-summaries.import', ['summaryPage' => $courierSummaryPage]) }}">
|
||||
@csrf
|
||||
<input id="courier-summary-import-{{ $courierSummaryPage }}" type="file" name="import" accept=".xlsx,.xls,.csv" required hidden onchange="this.form.submit()">
|
||||
<label class="courier-summary__button" for="courier-summary-import-{{ $courierSummaryPage }}">
|
||||
@if ($courierSummaryImportTemplate)
|
||||
<button type="button" class="courier-summary__button" data-courier-summary-import-open>
|
||||
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
|
||||
<span>导入</span>
|
||||
</label>
|
||||
</form>
|
||||
</button>
|
||||
@endif
|
||||
<a
|
||||
href="{{ route('business.courier-payable-summaries.export', ['summaryPage' => $courierSummaryPage]) }}"
|
||||
@class(['courier-summary__button', 'is-disabled' => $courierSummaryRows->total() === 0])
|
||||
@if ($courierSummaryRows->total() === 0) aria-disabled="true" tabindex="-1" @endif
|
||||
href="{{ route('business.courier-payable-summaries.export', array_filter([
|
||||
'summaryPage' => $courierSummaryPage,
|
||||
...$courierSummaryFilters,
|
||||
], static fn ($value) => $value !== '')) }}"
|
||||
@class(['courier-summary__button', 'is-disabled' => $courierSummarySourceTotal === 0])
|
||||
@if ($courierSummarySourceTotal === 0) aria-disabled="true" tabindex="-1" @endif
|
||||
>
|
||||
<span class="material-icons-outlined" aria-hidden="true">download</span>
|
||||
<span>导出</span>
|
||||
@ -44,6 +72,40 @@
|
||||
<div class="courier-summary__notice is-error" role="alert">{{ $courierSummaryError }}</div>
|
||||
@endif
|
||||
|
||||
<form
|
||||
class="courier-summary__filters"
|
||||
method="GET"
|
||||
action="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => $courierSummaryPage]) }}"
|
||||
aria-label="{{ $courierSummaryTitle }}查询条件"
|
||||
>
|
||||
<label class="courier-summary__filter">
|
||||
<span>日期</span>
|
||||
<input type="text" name="date" value="{{ $courierSummaryFilters['date'] }}" maxlength="50" placeholder="如 2026-07">
|
||||
</label>
|
||||
<label class="courier-summary__filter">
|
||||
<span>负责人</span>
|
||||
<input type="text" name="person" value="{{ $courierSummaryFilters['person'] }}" maxlength="100" placeholder="请输入负责人">
|
||||
</label>
|
||||
<label class="courier-summary__filter">
|
||||
<span>快递公司</span>
|
||||
<input type="text" name="courier" value="{{ $courierSummaryFilters['courier'] }}" maxlength="100" placeholder="快递公司 / 名称">
|
||||
</label>
|
||||
<label class="courier-summary__filter is-keyword">
|
||||
<span>关键词</span>
|
||||
<input type="text" name="keyword" value="{{ $courierSummaryFilters['keyword'] }}" maxlength="191" placeholder="运单号 / 交易号 / 条码 / 店铺 / 备注">
|
||||
</label>
|
||||
<div class="courier-summary__filter-actions">
|
||||
<button type="submit" class="courier-summary__button is-primary">
|
||||
<span class="material-icons-outlined" aria-hidden="true">search</span>
|
||||
<span>查询</span>
|
||||
</button>
|
||||
<a class="courier-summary__button" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => $courierSummaryPage]) }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
|
||||
<span>重置</span>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="courier-summary__table-wrap">
|
||||
<table class="courier-summary__table">
|
||||
<thead>
|
||||
@ -64,7 +126,13 @@
|
||||
</tr>
|
||||
@empty
|
||||
<tr class="courier-summary__empty">
|
||||
<td colspan="{{ max(count($courierSummaryHeaders) + 1, 1) }}">暂无数据,请先点击“导入”上传汇总表。</td>
|
||||
<td colspan="{{ max(count($courierSummaryHeaders) + 1, 1) }}">
|
||||
@if ($courierSummaryHasFilters && $courierSummarySourceTotal > 0)
|
||||
当前查询条件下没有匹配数据。
|
||||
@else
|
||||
暂无数据,请先点击“导入”上传汇总表。
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
@ -77,6 +145,61 @@
|
||||
<span>{{ $courierSummaryRows->onEachSide(1)->links() }}</span>
|
||||
</footer>
|
||||
@endif
|
||||
|
||||
@if ($courierSummaryImportTemplate)
|
||||
<dialog class="courier-summary__import-dialog" data-courier-summary-import-dialog aria-labelledby="courier-summary-import-title">
|
||||
<form
|
||||
method="POST"
|
||||
enctype="multipart/form-data"
|
||||
action="{{ route('business.courier-payable-summaries.import', ['summaryPage' => $courierSummaryPage]) }}"
|
||||
data-courier-summary-import-form
|
||||
>
|
||||
@csrf
|
||||
<header class="courier-summary__import-header">
|
||||
<div>
|
||||
<strong id="courier-summary-import-title">{{ $courierSummaryImportTemplate['dialog_title'] }}</strong>
|
||||
<span>支持 .xlsx、.xls、.csv,单个文件不超过 10 MB</span>
|
||||
</div>
|
||||
<button type="button" class="courier-summary__icon-button" data-courier-summary-import-close aria-label="关闭导入窗口" title="关闭">
|
||||
<span class="material-icons-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="courier-summary__import-body">
|
||||
<div class="courier-summary__template-row">
|
||||
<span class="material-icons-outlined" aria-hidden="true">description</span>
|
||||
<div>
|
||||
<strong>{{ $courierSummaryImportTemplate['template_name'] }}</strong>
|
||||
<span>.xlsx · {{ $courierSummaryImportTemplate['field_count'] }} 个字段</span>
|
||||
</div>
|
||||
<a class="courier-summary__button" href="{{ route('business.courier-payable-summaries.template', ['summaryPage' => $courierSummaryPage]) }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">download</span>
|
||||
<span>下载模板</span>
|
||||
</a>
|
||||
</div>
|
||||
<label class="courier-summary__file-picker" for="courier-summary-import-{{ $courierSummaryPage }}" role="button" tabindex="0" data-courier-summary-file-picker>
|
||||
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
|
||||
<span data-courier-summary-file-name>选择填写完成的文件</span>
|
||||
</label>
|
||||
<input
|
||||
id="courier-summary-import-{{ $courierSummaryPage }}"
|
||||
type="file"
|
||||
name="import"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
required
|
||||
hidden
|
||||
data-courier-summary-file-input
|
||||
>
|
||||
</div>
|
||||
<footer class="courier-summary__import-footer">
|
||||
<button type="button" class="courier-summary__button" data-courier-summary-import-close>取消</button>
|
||||
<button type="submit" class="courier-summary__button is-primary" data-courier-summary-import-submit disabled>
|
||||
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
|
||||
<span>导入</span>
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@ -91,11 +214,21 @@
|
||||
.courier-summary__button:hover { background:var(--vip-content-accent-soft,#f7f0de); color:var(--vip-content-accent-dark,#856632); text-decoration:none; }
|
||||
.courier-summary__button:focus-visible { outline:0; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
|
||||
.courier-summary__button .material-icons-outlined { font-size:17px; }
|
||||
.courier-summary__button.is-primary { border-color:var(--vip-content-accent-dark,#856632); background:var(--vip-content-accent-dark,#856632); color:#fff; }
|
||||
.courier-summary__button.is-primary:hover { background:#6a5129; color:#fff; }
|
||||
.courier-summary__button:disabled { cursor:not-allowed; opacity:.45; }
|
||||
.courier-summary__button.is-disabled { pointer-events:none; opacity:.45; }
|
||||
.courier-summary__notice { padding:10px 12px; border-bottom:1px solid var(--vip-border,#d8d2c7); font-size:.75rem; }
|
||||
.courier-summary__notice.is-success { background:#f3f8ef; color:var(--vip-success,#53793e); }
|
||||
.courier-summary__notice.is-error { background:#fff5f5; color:var(--vip-danger,#b80000); }
|
||||
.courier-summary__table-wrap { max-height:calc(100vh - 315px); min-height:180px; overflow:auto; }
|
||||
.courier-summary__filters { display:grid; grid-template-columns:minmax(130px,.75fr) minmax(130px,.75fr) minmax(150px,1fr) minmax(240px,1.5fr) auto; align-items:end; gap:10px; padding:10px 12px; border-bottom:1px solid var(--vip-border,#d8d2c7); background:#fff; }
|
||||
.courier-summary__filter { display:grid; min-width:0; gap:4px; margin:0; }
|
||||
.courier-summary__filter>span { color:var(--vip-content-muted,#595959); font-size:.75rem; font-weight:500; line-height:1.333; }
|
||||
.courier-summary__filter input { width:100%; height:34px; min-width:0; padding:0 10px; border:1px solid var(--vip-border,#d8d2c7); border-radius:8px; background:#fff; color:var(--vip-content-ink,#171717); font:inherit; font-size:.8125rem; }
|
||||
.courier-summary__filter input::placeholder { color:var(--vip-content-muted,#595959); opacity:1; }
|
||||
.courier-summary__filter input:focus { outline:0; border-color:var(--vip-content-accent-dark,#856632); box-shadow:0 0 0 3px rgba(133,102,50,.2); }
|
||||
.courier-summary__filter-actions { display:flex; align-items:center; gap:8px; }
|
||||
.courier-summary__table-wrap { max-height:calc(100vh - 385px); min-height:180px; overflow:auto; }
|
||||
.courier-summary__table { width:max-content; min-width:100%; border-collapse:collapse; font-size:.78125rem; font-variant-numeric:tabular-nums; }
|
||||
.courier-summary__table th,.courier-summary__table td { min-width:128px; height:38px; padding:7px 10px; border-right:1px solid var(--vip-border,#d8d2c7); border-bottom:1px solid var(--vip-border,#d8d2c7); text-align:left; white-space:nowrap; }
|
||||
.courier-summary__table th { position:sticky; top:0; z-index:2; background:var(--vip-content-accent-soft,#f7f0de); font-size:.75rem; font-weight:600; }
|
||||
@ -104,5 +237,64 @@
|
||||
.courier-summary__table td.courier-summary__index { position:sticky; background:#fff; }
|
||||
.courier-summary__empty td { height:160px; color:var(--vip-content-muted,#595959); text-align:center; }
|
||||
.courier-summary__pagination { display:flex; min-height:44px; align-items:center; justify-content:space-between; gap:16px; padding:6px 12px; color:var(--vip-content-muted,#595959); font-size:.75rem; }
|
||||
@media (max-width:700px) { .courier-summary__toolbar { align-items:flex-start; flex-direction:column; } .courier-summary__actions { width:100%; } }
|
||||
.courier-summary__import-dialog { width:min(520px,calc(100vw - 32px)); padding:0; overflow:hidden; border:1px solid var(--vip-border,#d8d2c7); border-radius:8px; background:#fff; color:var(--vip-content-ink,#171717); box-shadow:0 4px 8px rgba(23,23,23,.1); }
|
||||
.courier-summary__import-dialog::backdrop { background:rgba(5,5,6,.45); }
|
||||
.courier-summary__import-header { display:flex; min-height:60px; align-items:center; justify-content:space-between; gap:16px; padding:12px 16px; border-bottom:1px solid var(--vip-border,#d8d2c7); background:var(--vip-content-bg,#f8f6f1); }
|
||||
.courier-summary__import-header strong,.courier-summary__import-header span { display:block; }
|
||||
.courier-summary__import-header strong { font-size:.875rem; }
|
||||
.courier-summary__import-header div>span { margin-top:3px; color:var(--vip-content-muted,#595959); font-size:.75rem; }
|
||||
.courier-summary__icon-button { display:inline-flex; width:34px; height:34px; flex:0 0 34px; align-items:center; justify-content:center; padding:0; border:0; border-radius:4px; background:transparent; color:var(--vip-content-muted,#595959); cursor:pointer; }
|
||||
.courier-summary__icon-button:hover { background:var(--vip-content-accent-soft,#f7f0de); color:var(--vip-content-accent-dark,#856632); }
|
||||
.courier-summary__icon-button:focus-visible { outline:0; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
|
||||
.courier-summary__import-body { display:grid; gap:12px; padding:16px; }
|
||||
.courier-summary__template-row { display:grid; grid-template-columns:34px minmax(0,1fr) auto; align-items:center; gap:10px; min-height:54px; padding:9px 10px; border:1px solid var(--vip-border,#d8d2c7); border-radius:8px; }
|
||||
.courier-summary__template-row>.material-icons-outlined { color:var(--vip-content-accent-dark,#856632); }
|
||||
.courier-summary__template-row strong,.courier-summary__template-row div>span { display:block; }
|
||||
.courier-summary__template-row strong { font-size:.8125rem; }
|
||||
.courier-summary__template-row div>span { margin-top:2px; color:var(--vip-content-muted,#595959); font-size:.75rem; }
|
||||
.courier-summary__file-picker { display:flex; min-height:68px; align-items:center; justify-content:center; gap:8px; padding:12px; border:1px dashed var(--vip-content-accent,#b9954f); border-radius:8px; background:var(--vip-content-accent-soft,#f7f0de); color:var(--vip-content-accent-dark,#856632); font-size:.8125rem; font-weight:500; cursor:pointer; }
|
||||
.courier-summary__file-picker:hover { border-color:var(--vip-content-accent-dark,#856632); }
|
||||
.courier-summary__file-picker:focus-within { box-shadow:0 0 0 3px rgba(133,102,50,.2); }
|
||||
.courier-summary__import-footer { display:flex; min-height:58px; align-items:center; justify-content:flex-end; gap:8px; padding:12px 16px; border-top:1px solid var(--vip-border,#d8d2c7); background:var(--vip-content-bg,#f8f6f1); }
|
||||
@media (max-width:1100px) { .courier-summary__filters { grid-template-columns:repeat(2,minmax(150px,1fr)); } .courier-summary__filter.is-keyword { grid-column:span 2; } .courier-summary__filter-actions { justify-content:flex-end; } }
|
||||
@media (max-width:700px) { .courier-summary__toolbar { align-items:flex-start; flex-direction:column; } .courier-summary__actions { width:100%; } .courier-summary__filters { grid-template-columns:1fr; } .courier-summary__filter.is-keyword { grid-column:auto; } .courier-summary__filter-actions { justify-content:flex-start; } .courier-summary__template-row { grid-template-columns:28px minmax(0,1fr); } .courier-summary__template-row .courier-summary__button { grid-column:1/-1; justify-content:center; } }
|
||||
</style>
|
||||
|
||||
@if ($courierSummaryImportTemplate)
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.querySelector('[data-courier-payable-summary="{{ $courierSummaryPage }}"]');
|
||||
if (!root) return;
|
||||
|
||||
const dialog = root.querySelector('[data-courier-summary-import-dialog]');
|
||||
const openButton = root.querySelector('[data-courier-summary-import-open]');
|
||||
const closeButtons = [...root.querySelectorAll('[data-courier-summary-import-close]')];
|
||||
const filePicker = root.querySelector('[data-courier-summary-file-picker]');
|
||||
const fileInput = root.querySelector('[data-courier-summary-file-input]');
|
||||
const fileName = root.querySelector('[data-courier-summary-file-name]');
|
||||
const submitButton = root.querySelector('[data-courier-summary-import-submit]');
|
||||
const form = root.querySelector('[data-courier-summary-import-form]');
|
||||
|
||||
const close = () => dialog.close();
|
||||
openButton.addEventListener('click', () => dialog.showModal());
|
||||
closeButtons.forEach(button => button.addEventListener('click', close));
|
||||
dialog.addEventListener('click', event => {
|
||||
if (event.target === dialog) close();
|
||||
});
|
||||
filePicker.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
fileInput.click();
|
||||
});
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files?.[0];
|
||||
fileName.textContent = file ? file.name : '选择填写完成的文件';
|
||||
submitButton.disabled = !file;
|
||||
});
|
||||
form.addEventListener('submit', () => {
|
||||
submitButton.disabled = true;
|
||||
submitButton.querySelector('span:last-child').textContent = '正在导入';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endif
|
||||
|
||||
@ -0,0 +1,276 @@
|
||||
@php
|
||||
$platforms = [
|
||||
'taobao' => ['label' => '淘系', 'icon' => 'shopping_bag', 'description' => '集中查看淘宝、天猫店铺的违规和处罚风险。'],
|
||||
'alibaba' => ['label' => '阿里巴巴', 'icon' => 'inventory_2', 'description' => '集中查看阿里巴巴店铺的违规和处罚风险。'],
|
||||
'jd' => ['label' => '京东', 'icon' => 'local_mall', 'description' => '集中查看京东店铺的违规和处罚风险。'],
|
||||
'pinduoduo' => ['label' => '拼多多', 'icon' => 'grid_view', 'description' => '集中查看拼多多店铺的违规和处罚风险。'],
|
||||
'douyin' => ['label' => '抖音', 'icon' => 'play_circle', 'description' => '集中查看抖音店铺的违规和处罚风险。'],
|
||||
];
|
||||
$requestedPlatform = (string) request()->query('platform', 'taobao');
|
||||
$activePlatform = array_key_exists($requestedPlatform, $platforms) ? $requestedPlatform : 'taobao';
|
||||
@endphp
|
||||
|
||||
<style>
|
||||
.financial-risk-control {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.financial-risk-control__tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid var(--vip-border, #D8D2C7);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--vip-surface, #FFFFFF);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--vip-content-accent, #B9954F) transparent;
|
||||
}
|
||||
|
||||
.financial-risk-control__tabs::-webkit-scrollbar {
|
||||
height: 0.375rem;
|
||||
}
|
||||
|
||||
.financial-risk-control__tabs::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: var(--vip-content-accent, #B9954F);
|
||||
}
|
||||
|
||||
.financial-risk-control__tab {
|
||||
display: inline-flex;
|
||||
min-width: 7.5rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0 1rem;
|
||||
border: 0;
|
||||
border-bottom: 0.1875rem solid transparent;
|
||||
border-radius: 0.5rem 0.5rem 0 0;
|
||||
background: transparent;
|
||||
color: var(--vip-content-muted, #595959);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.financial-risk-control__tab:hover,
|
||||
.financial-risk-control__tab:focus-visible {
|
||||
background: var(--vip-content-accent-soft, #F7F0DE);
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.financial-risk-control__tab.is-active {
|
||||
border-bottom-color: var(--vip-content-accent, #B9954F);
|
||||
background: var(--vip-content-accent-soft, #F7F0DE);
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
}
|
||||
|
||||
.financial-risk-control__tab .material-icons-outlined {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.financial-risk-control__panel {
|
||||
min-height: 22rem;
|
||||
margin-top: 1rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--vip-border, #D8D2C7);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--vip-surface, #FFFFFF);
|
||||
color: var(--vip-content-ink, #171717);
|
||||
}
|
||||
|
||||
.financial-risk-control__panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.financial-risk-control__panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--vip-border, #D8D2C7);
|
||||
}
|
||||
|
||||
.financial-risk-control__panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
color: var(--vip-content-ink, #171717);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.financial-risk-control__panel-title .material-icons-outlined {
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.financial-risk-control__panel-description {
|
||||
max-width: 36rem;
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--vip-content-muted, #595959);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.financial-risk-control__status {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: 1px solid rgba(147, 95, 7, 0.35);
|
||||
border-radius: 0.5rem;
|
||||
background: #FFFAF0;
|
||||
color: var(--vip-warning, #935F07);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.333;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.financial-risk-control__empty {
|
||||
display: flex;
|
||||
min-height: 15rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.financial-risk-control__empty-icon {
|
||||
display: inline-flex;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.875rem;
|
||||
border-radius: 50%;
|
||||
background: var(--vip-content-accent-soft, #F7F0DE);
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
}
|
||||
|
||||
.financial-risk-control__empty-icon .material-icons-outlined {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.financial-risk-control__empty-title {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.financial-risk-control__empty-description {
|
||||
max-width: 32rem;
|
||||
margin: 0.5rem auto 0;
|
||||
color: var(--vip-content-muted, #595959);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.financial-risk-control__panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.financial-risk-control__panel-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<section
|
||||
class="financial-risk-control"
|
||||
data-financial-risk-tabs
|
||||
data-active-platform="{{ $activePlatform }}"
|
||||
aria-label="店铺违规信息预警平台"
|
||||
>
|
||||
<nav class="financial-risk-control__tabs" role="tablist" aria-label="平台">
|
||||
@foreach ($platforms as $slug => $platform)
|
||||
<button
|
||||
type="button"
|
||||
class="financial-risk-control__tab {{ $activePlatform === $slug ? 'is-active' : '' }}"
|
||||
id="financial-risk-control-tab-{{ $slug }}"
|
||||
role="tab"
|
||||
aria-controls="financial-risk-control-panel-{{ $slug }}"
|
||||
aria-selected="{{ $activePlatform === $slug ? 'true' : 'false' }}"
|
||||
data-platform-tab="{{ $slug }}"
|
||||
>
|
||||
<span class="material-icons-outlined" aria-hidden="true">{{ $platform['icon'] }}</span>
|
||||
<span>{{ $platform['label'] }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
@foreach ($platforms as $slug => $platform)
|
||||
<section
|
||||
class="financial-risk-control__panel"
|
||||
id="financial-risk-control-panel-{{ $slug }}"
|
||||
role="tabpanel"
|
||||
aria-labelledby="financial-risk-control-tab-{{ $slug }}"
|
||||
data-platform-panel="{{ $slug }}"
|
||||
@if ($activePlatform !== $slug) hidden @endif
|
||||
>
|
||||
<header class="financial-risk-control__panel-header">
|
||||
<div>
|
||||
<h2 class="financial-risk-control__panel-title">
|
||||
<span class="material-icons-outlined" aria-hidden="true">{{ $platform['icon'] }}</span>
|
||||
<span>{{ $platform['label'] }}店铺违规信息预警</span>
|
||||
</h2>
|
||||
<p class="financial-risk-control__panel-description">{{ $platform['description'] }}</p>
|
||||
</div>
|
||||
<span class="financial-risk-control__status">数据源尚未接入</span>
|
||||
</header>
|
||||
|
||||
<div class="financial-risk-control__empty">
|
||||
<div>
|
||||
<span class="financial-risk-control__empty-icon" aria-hidden="true">
|
||||
<span class="material-icons-outlined">warning_amber</span>
|
||||
</span>
|
||||
<h3 class="financial-risk-control__empty-title">暂无{{ $platform['label'] }}风险事项</h3>
|
||||
<p class="financial-risk-control__empty-description">{{ $platform['label'] }}违规规则和数据接入后,相关事项将在此展示。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endforeach
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.querySelector('[data-financial-risk-tabs]');
|
||||
if (!root || root.dataset.bound === 'true') return;
|
||||
|
||||
root.dataset.bound = 'true';
|
||||
const tabs = [...root.querySelectorAll('[data-platform-tab]')];
|
||||
const panels = [...root.querySelectorAll('[data-platform-panel]')];
|
||||
const validPlatforms = new Set(tabs.map((tab) => tab.dataset.platformTab));
|
||||
|
||||
const setPlatform = (platform, updateUrl = true) => {
|
||||
const active = validPlatforms.has(platform) ? platform : tabs[0]?.dataset.platformTab;
|
||||
if (!active) return;
|
||||
|
||||
root.dataset.activePlatform = active;
|
||||
tabs.forEach((tab) => {
|
||||
const selected = tab.dataset.platformTab === active;
|
||||
tab.classList.toggle('is-active', selected);
|
||||
tab.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
});
|
||||
panels.forEach((panel) => {
|
||||
panel.hidden = panel.dataset.platformPanel !== active;
|
||||
});
|
||||
|
||||
if (updateUrl) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('platform', active);
|
||||
window.history.pushState({ platform: active }, '', url);
|
||||
}
|
||||
};
|
||||
|
||||
tabs.forEach((tab) => tab.addEventListener('click', () => setPlatform(tab.dataset.platformTab)));
|
||||
window.addEventListener('popstate', () => setPlatform(new URL(window.location.href).searchParams.get('platform'), false));
|
||||
})();
|
||||
</script>
|
||||
@ -369,6 +369,11 @@
|
||||
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
|
||||
<span>{{ $upstreamError }}</span>
|
||||
</div>
|
||||
@elseif (($directory['status'] ?? null) === '演示数据' && $directoryTotal > 0)
|
||||
<div class="directory-table-notice" role="status">
|
||||
<span class="material-icons-outlined" aria-hidden="true">preview</span>
|
||||
<span>已按附件模板导入 {{ $directoryTotal }} 条演示数据,当前页面为只读展示。</span>
|
||||
</div>
|
||||
@elseif ($directoryTotal > 0)
|
||||
<div class="directory-table-notice" role="status">
|
||||
<span class="material-icons-outlined" aria-hidden="true">cloud_done</span>
|
||||
@ -447,6 +452,32 @@
|
||||
<a href="{{ request()->url() }}" class="biz-btn biz-btn-ghost">清除</a>
|
||||
</div>
|
||||
</form>
|
||||
@elseif (($page['slug'] ?? '') === 'inventory-report')
|
||||
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="进销存报表筛选查询栏">
|
||||
<label class="installation-filter-field">
|
||||
<span>月份:</span>
|
||||
<input type="month" name="month" min="2026-08" value="{{ request('month', now()->startOfMonth()->subMonth()->format('Y-m')) }}" aria-label="进销存报表月份">
|
||||
</label>
|
||||
<label class="installation-filter-field">
|
||||
<span>商品编码:</span>
|
||||
<input type="text" name="product_code" value="{{ request('product_code') }}" placeholder="按商品编码精确查询">
|
||||
</label>
|
||||
<label class="installation-filter-field">
|
||||
<span>仓储方:</span>
|
||||
<input type="text" name="warehouse_provider" value="{{ request('warehouse_provider') }}" placeholder="支持包含查询">
|
||||
</label>
|
||||
<label class="installation-filter-field">
|
||||
<span>分类:</span>
|
||||
<input type="text" name="category" value="{{ request('category') }}" placeholder="支持包含查询">
|
||||
</label>
|
||||
<div class="installation-filter-actions">
|
||||
<button type="submit" class="biz-btn biz-btn-primary">查询</button>
|
||||
<a href="{{ request()->url() }}" class="biz-btn biz-btn-ghost">清除</a>
|
||||
<a href="{{ route('business.product-inventory.inventory-report.export', request()->except('page')) }}" class="biz-btn biz-btn-ghost" aria-label="导出当前筛选结果" title="导出当前筛选结果">
|
||||
<span class="material-icons-outlined" aria-hidden="true">download</span>导出
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
<div class="directory-table-tools" data-directory-tools>
|
||||
<label class="sr-only" for="directory-search-{{ $page['slug'] ?? 'page' }}">搜索当前表格</label>
|
||||
@ -472,7 +503,7 @@
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
@foreach ($directoryFields as $field)
|
||||
<th scope="col" data-column-key="{{ $field['name'] }}" class="{{ ($field['kind'] ?? null) === 'number' ? 'is-number' : '' }}">{{ $field['label'] }}</th>
|
||||
<th scope="col" data-column-key="{{ $field['name'] }}" class="{{ in_array($field['kind'] ?? null, ['number', 'integer'], true) ? 'is-number' : '' }}">{{ $field['label'] }}</th>
|
||||
@endforeach
|
||||
</tr>
|
||||
</thead>
|
||||
@ -496,11 +527,13 @@
|
||||
$display = substr((string) $value, 0, 10);
|
||||
} elseif ($kind === 'number' && is_numeric($value)) {
|
||||
$display = number_format((float) $value, 2);
|
||||
} elseif ($kind === 'integer' && is_numeric($value)) {
|
||||
$display = number_format((int) $value);
|
||||
} elseif ($kind === 'boolean' && $value !== null && $value !== '') {
|
||||
$display = in_array($value, [true, 1, '1', '是', '✔'], true) ? '是' : '否';
|
||||
}
|
||||
@endphp
|
||||
<td data-column-key="{{ $field['name'] }}" class="{{ $kind === 'number' ? 'is-number' : '' }}">
|
||||
<td data-column-key="{{ $field['name'] }}" class="{{ in_array($kind, ['number', 'integer'], true) ? 'is-number' : '' }}">
|
||||
@if (($page['slug'] ?? '') === 'store-accounts' && $field['name'] === 'password_configured')
|
||||
<span class="sr-only" data-password-status>{{ filled($display) ? $display : '—' }}</span>
|
||||
@if ($value === false)
|
||||
|
||||
@ -611,6 +611,7 @@
|
||||
|
||||
@php
|
||||
$pageView = null;
|
||||
$sectionView = null;
|
||||
|
||||
if ($page) {
|
||||
$viewName = 'business.pages.' . $section['slug'] . '.' . $page['slug'];
|
||||
@ -622,11 +623,19 @@
|
||||
if (view()->exists($viewName)) {
|
||||
$pageView = $viewName;
|
||||
}
|
||||
} else {
|
||||
$viewName = 'business.pages.' . $section['slug'] . '.index';
|
||||
|
||||
if (view()->exists($viewName)) {
|
||||
$sectionView = $viewName;
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
@if (in_array($section['slug'], ['courier-payables', 'courier-payables-final'], true) && empty($page))
|
||||
@include('business.pages.courier-payables.final-workspace')
|
||||
@elseif ($sectionView)
|
||||
@include($sectionView)
|
||||
@elseif (!empty($content) && !empty($content['view']))
|
||||
<section class="business-content-section" aria-label="{{ $activeContentTitle }}">
|
||||
@include($content['view'], $content)
|
||||
|
||||
@ -128,10 +128,10 @@
|
||||
<p>乙 方:<span class="agreement-preview-value" data-preview-bind="cooperationPartyBName">待填写</span></p>
|
||||
<p>代表人:</p>
|
||||
<p>代表人:</p>
|
||||
<p>日 期:<span data-preview-bind="signatureDate">签署后生成</span></p>
|
||||
<p>日 期:<span data-preview-bind="signatureDate">签署后生成</span></p>
|
||||
<p>日 期:<span data-preview-bind="signatureDate">待填写</span></p>
|
||||
<p>日 期:<span data-preview-bind="signatureDate">待填写</span></p>
|
||||
<p>签约地:常州市</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="sign"><p>甲 方:{!! $mixedText($payload['cooperation_party_a_name'] ?? '') !!}</p><p>乙 方:{!! $mixedText($payload['cooperation_party_b_name'] ?? '') !!}</p><p>代表人:</p><p>代表人:</p><p>日 期:签署后生成</p><p>日 期:签署后生成</p><p>签约地:常州市</p></div>
|
||||
<div class="sign"><p>甲 方:{!! $mixedText($payload['cooperation_party_a_name'] ?? '') !!}</p><p>乙 方:{!! $mixedText($payload['cooperation_party_b_name'] ?? '') !!}</p><p>代表人:</p><p>代表人:</p><p>日 期:{!! $mixedText($signatureDate) !!}</p><p>日 期:{!! $mixedText($signatureDate) !!}</p><p>签约地:常州市</p></div>
|
||||
@endif
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
h1 { text-align: center; font-size: 20px; margin: 0 0 24px; }
|
||||
h2 { font-size: 14px; margin: 18px 0 8px; }
|
||||
h4 { font-size: 14px; margin: 18px 0 8px; }
|
||||
p { margin: 0 0 12px; }
|
||||
p { margin: 0 0 12px; overflow-wrap: anywhere; word-break: break-all; }
|
||||
.meta { color: #595959; font-size: 11px; text-align: right; margin-bottom: 20px; }
|
||||
.account { margin: 14px 0 20px 28px; }
|
||||
.account div { margin: 6px 0; }
|
||||
@ -57,7 +57,7 @@
|
||||
</div>
|
||||
<p>请贵公司按照上述汇款资料付款,如因我司提供收款银行帐户资料错误,导致相关款项支付不到位,则由此产生的一切经济纠纷和责任由我司承担。我司承诺若上述收款银行帐户资料发生变化及时书面通知贵公司。</p>
|
||||
<p>以上收款账户的变更,以我司的盖章为有效。</p>
|
||||
<div class="sign"><p>公司盖章:</p><p>日期:签署后生成</p></div>
|
||||
<div class="sign"><p>公司盖章:</p><p>日期:{!! $mixedText($signatureDate) !!}</p></div>
|
||||
@elseif ($type === 'account-change')
|
||||
<p><span class="latin">TO</span> <strong>{!! $mixedText($payload['company_full_name']) !!}</strong>:</p>
|
||||
<p>基于我公司正规化管理的需要,同时保证广大客户的汇款安全,我司收款账户将于 {!! $mixedText($payload['account_change_date']) !!} 变更账户,之前相应的账户(收款账户为:{!! $mixedText($payload['old_account_name']) !!},账号:{!! $mixedText($payload['old_account_number']) !!})于 {!! $mixedText($payload['old_account_stop_date']) !!} 停止使用,即日起我司收款的银行帐户进行变更,自 {!! $mixedText($payload['new_account_effective_date']) !!} 起我司与贵司往来款项请汇款至以下新账户,新汇款资料如下:</p>
|
||||
@ -67,20 +67,20 @@
|
||||
<div>银行账户:{!! $mixedText($payload['new_bank_account']) !!}</div>
|
||||
</div>
|
||||
<p>如因我司提供收款银行帐户资料错误导致相关款项支付不到位,由此产生的一切经济纠纷和责任由我司承担。以上收款账户的变更,以我司的盖章为有效。</p>
|
||||
<div class="sign"><p>公司盖章:</p><p>日期:签署后生成</p></div>
|
||||
<div class="sign"><p>公司盖章:</p><p>日期:{!! $mixedText($signatureDate) !!}</p></div>
|
||||
@elseif ($type === 'entity-change')
|
||||
<p>致 <strong>{!! $mixedText($payload['company_full_name']) !!}</strong>:</p>
|
||||
<p>我司由于企业名称变更,原公司名称 {!! $mixedText($payload['former_company_name']) !!} 于 {!! $mixedText($payload['entity_change_date']) !!} 变更为 {!! $mixedText($payload['changed_company_name']) !!},自 {!! $mixedText($payload['changed_company_start_date']) !!} 开始,我司将启用变更后的公司与贵公司开展工作,原公司名称停止使用。</p>
|
||||
<p>变更后,原公司的各项事宜由变更后的公司继续执行。原公司的结算未付款项,由变更之后的公司提供的收款账户收款。原公司的业务和款项若出现法律上的纠纷,由变更后的公司承担责任。</p>
|
||||
<p>特此证明!</p>
|
||||
<div class="sign"><p>变更前公司法人签字盖公章:{!! $mixedText($payload['former_company_name']) !!}</p><p>变更后公司法人签字盖公章:{!! $mixedText($payload['changed_company_name']) !!}</p><p>日期:{!! $mixedText($payload['entity_proof_date']) !!}</p></div>
|
||||
<div class="sign"><p>变更前公司法人签字盖公章:{!! $mixedText($payload['former_company_name']) !!}</p><p>变更后公司法人签字盖公章:{!! $mixedText($payload['changed_company_name']) !!}</p><p>日期:{!! $mixedText($signatureDate) !!}</p></div>
|
||||
@elseif ($type === 'shared-contract')
|
||||
<p>与 {!! $mixedText($payload['contract_partner_name'] ?? '') !!} 合作的 {!! $mixedText($sharedCourierText) !!} 共同用一份合同,打款资料为同一份资料,同一个签字人以及同一个法人!</p>
|
||||
<p>特此证明!</p>
|
||||
@if (filled($payload['shared_note'] ?? null))
|
||||
<p>备注:{!! $mixedText($payload['shared_note']) !!}</p>
|
||||
@endif
|
||||
<div class="sign"><p>法人签字并公司盖章:</p><p>日期:{!! $mixedText($payload['shared_proof_date'] ?? '签署后生成') !!}</p></div>
|
||||
<div class="sign"><p>法人签字并公司盖章:</p><p>日期:{!! $mixedText($signatureDate) !!}</p></div>
|
||||
@elseif ($type === 'cooperation-contract')
|
||||
@include('business.partials.cooperation-contract', ['mode' => 'pdf', 'payload' => $payload])
|
||||
@else
|
||||
@ -98,7 +98,7 @@
|
||||
<li>代表委托方代收 {!! $mixedText($payload['payment_counterparty']) !!} 的往来货款。</li>
|
||||
</ol>
|
||||
<p>委托有效期:从 {!! $mixedText($payload['authorization_start_date']) !!} 至 {!! $mixedText($payload['authorization_end_date']) !!} 止。</p>
|
||||
<div class="sign"><p>委托方:{!! $mixedText($payload['principal_name']) !!}</p><p>受托方:{!! $mixedText($payload['trustee_name']) !!}</p><p>日期:签署后生成</p></div>
|
||||
<div class="sign"><p>委托方:{!! $mixedText($payload['principal_name']) !!}</p><p>受托方:{!! $mixedText($payload['trustee_name']) !!}</p><p>日期:{!! $mixedText($signatureDate) !!}</p></div>
|
||||
@endif
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -134,7 +134,7 @@
|
||||
<section class="signature">
|
||||
<h2>签署确认</h2>
|
||||
<div class="signature__meta">报价版本:{!! $mixedText($version->version_code) !!} 快递公司:{!! $mixedText($version->carrier_name) !!} 执行日期:{!! $mixedText($version->effective_date?->toDateString()) !!}</div>
|
||||
<div class="signature__box">乙方(快递方)签章:{!! $mixedText($version->carrier_name) !!}<br><br>签署日期:</div>
|
||||
<div class="signature__box">乙方(快递方)签章:{!! $mixedText($version->carrier_name) !!}<br><br>签署日期:{!! $mixedText($signatureDate) !!}</div>
|
||||
<div class="document-control">生成时间:{!! $mixedText($generatedAt->format('Y-m-d H:i:s')) !!} · 版本 {!! $mixedText($version->version_code) !!}</div>
|
||||
</section>
|
||||
</body>
|
||||
|
||||
@ -129,6 +129,10 @@ Route::get('workspace/product-inventory/online-daifa-price/export', 'Business\Pa
|
||||
->middleware('business.page:product-inventory,online-daifa-price')
|
||||
->name('business.product-inventory.online-daifa-price.export');
|
||||
|
||||
Route::get('workspace/product-inventory/inventory-report/export', 'Business\Pages@exportInventoryReport')
|
||||
->middleware('business.page:product-inventory,inventory-report')
|
||||
->name('business.product-inventory.inventory-report.export');
|
||||
|
||||
Route::prefix('workspace/supplier-payables/product-prices')
|
||||
->name('business.supplier-payables.product-prices.')
|
||||
->middleware('business.page:supplier-payables,product-prices')
|
||||
@ -198,10 +202,13 @@ Route::prefix('workspace/courier-payables/courier-surcharge')
|
||||
});
|
||||
|
||||
Route::post('workspace/courier-payables/{summaryPage}/summary-import', [CourierPayableSummaries::class, 'import'])
|
||||
->where('summaryPage', 'freight-difference|other-surcharge-summary')
|
||||
->where('summaryPage', 'freight-difference|other-surcharge-summary|return-fee')
|
||||
->name('business.courier-payable-summaries.import');
|
||||
Route::get('workspace/courier-payables/{summaryPage}/summary-template', [CourierPayableSummaries::class, 'template'])
|
||||
->where('summaryPage', 'freight-difference|other-surcharge-summary|return-fee')
|
||||
->name('business.courier-payable-summaries.template');
|
||||
Route::get('workspace/courier-payables/{summaryPage}/summary-export', [CourierPayableSummaries::class, 'export'])
|
||||
->where('summaryPage', 'freight-difference|other-surcharge-summary')
|
||||
->where('summaryPage', 'freight-difference|other-surcharge-summary|return-fee')
|
||||
->name('business.courier-payable-summaries.export');
|
||||
|
||||
Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show')
|
||||
|
||||
@ -29,6 +29,7 @@ $dependencyFingerprintPath = Join-Path $frontendCacheRoot 'node-modules.sha'
|
||||
$jsManifestCachePath = Join-Path $frontendCacheRoot 'manifest-js.json'
|
||||
$cssManifestCachePath = Join-Path $frontendCacheRoot 'manifest-css.json'
|
||||
$cachedVendorPath = Join-Path $frontendOutputRoot 'vendor'
|
||||
$outputFingerprintPath = Join-Path $frontendCacheRoot 'frontend-output.sha'
|
||||
|
||||
function Invoke-Native {
|
||||
param(
|
||||
@ -147,6 +148,10 @@ $cssFingerprintInputs = @(
|
||||
$jsFingerprint = "$NodeVersion|$TerserWorkers|$(Get-PathFingerprint $jsFingerprintInputs)"
|
||||
$cssFingerprint = "$NodeVersion|$(Get-PathFingerprint $cssFingerprintInputs)"
|
||||
$dependencyFingerprint = "$NodeVersion|$packageLockHash"
|
||||
$outputCacheValid = (Test-Path -LiteralPath $outputFingerprintPath) -and
|
||||
(Test-Path -LiteralPath $frontendOutputRoot) -and
|
||||
((Get-Content -Raw -LiteralPath $outputFingerprintPath).Trim() -eq
|
||||
(Get-PathFingerprint @('.caiwu-build/public')))
|
||||
|
||||
$jsCacheValid = Test-CacheFingerprint `
|
||||
-FingerprintPath $jsFingerprintPath `
|
||||
@ -170,6 +175,12 @@ $cssCacheValid = Test-CacheFingerprint `
|
||||
$cssManifestCachePath
|
||||
)
|
||||
|
||||
if (-not $outputCacheValid) {
|
||||
$jsCacheValid = $false
|
||||
$cssCacheValid = $false
|
||||
Write-Host 'Frontend output integrity is missing or changed. Rebuilding assets.'
|
||||
}
|
||||
|
||||
if ($jsCacheValid -and $cssCacheValid) {
|
||||
Write-Host 'Frontend inputs unchanged. Reusing JavaScript, CSS, and dependency caches.'
|
||||
|
||||
@ -337,12 +348,19 @@ try {
|
||||
foreach ($manifestPath in @($jsManifestCachePath, $cssManifestCachePath)) {
|
||||
$manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json
|
||||
foreach ($property in $manifest.PSObject.Properties) {
|
||||
if ($manifestPath -eq $jsManifestCachePath -and $property.Name -notlike '*.js') { continue }
|
||||
if ($manifestPath -eq $cssManifestCachePath -and $property.Name -notlike '*.css') { continue }
|
||||
$mergedManifest[$property.Name] = $property.Value
|
||||
}
|
||||
}
|
||||
$mergedManifestJson = $mergedManifest | ConvertTo-Json
|
||||
Write-Utf8NoBom -Path (Join-Path $frontendOutputRoot 'mix-manifest.json') -Value $mergedManifestJson
|
||||
Write-Utf8NoBom -Path $publicManifestPath -Value $mergedManifestJson
|
||||
if ($jsFingerprint -ne "$NodeVersion|$TerserWorkers|$(Get-PathFingerprint $jsFingerprintInputs)" -or
|
||||
$cssFingerprint -ne "$NodeVersion|$(Get-PathFingerprint $cssFingerprintInputs)") {
|
||||
throw 'Frontend sources changed during build. Retry from a stable checkout.'
|
||||
}
|
||||
Write-Utf8NoBom -Path $outputFingerprintPath -Value (Get-PathFingerprint @('.caiwu-build/public'))
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
|
||||
@ -356,6 +356,20 @@ try {
|
||||
docker run --rm --network none --user www-data --entrypoint php $fullImage artisan --version
|
||||
} '发布镜像无法引导 Laravel。服务器未发生任何变更。'
|
||||
|
||||
# Compare the image's source and compiled assets with this workstation build.
|
||||
# This also catches a Docker COPY or stale build-context overwrite before upload.
|
||||
foreach ($asset in @(
|
||||
@{ Local = 'resources/assets/js/components/business/AccountBills.vue'; Image = 'resources/assets/js/components/business/AccountBills.vue' },
|
||||
@{ Local = '.caiwu-build/public/js/business/pages.min.js'; Image = 'public/js/business/pages.min.js' },
|
||||
@{ Local = '.caiwu-build/public/mix-manifest.json'; Image = 'public/mix-manifest.json' }
|
||||
)) {
|
||||
$expectedHash = (Get-FileHash -LiteralPath (Join-Path $repoRoot $asset.Local) -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$imageHash = @(& docker run --rm --network none --user www-data --entrypoint sha256sum $fullImage $asset.Image)
|
||||
if ($LASTEXITCODE -ne 0 -or ($imageHash -join ' ') -notmatch "^$expectedHash\s") {
|
||||
throw "发布镜像源码或前端资源不匹配:$($asset.Image)。服务器未发生任何变更。"
|
||||
}
|
||||
}
|
||||
|
||||
$previousShaOutput = @(& ssh @sshOptions -o StrictHostKeyChecking=accept-new $sshTarget "cat '$RemotePath/.deployed-sha' 2>/dev/null || true")
|
||||
$previousSha = ($previousShaOutput -join "`n").Trim()
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
|
||||
@ -67,7 +67,13 @@ class AgreementSigningPageTest extends FeatureTestCase
|
||||
->assertSee('data-company-name', false)
|
||||
->assertSee('data-subject-linked-name', false)
|
||||
->assertSeeText('签署截止日期')
|
||||
->assertSeeText('对方E签宝账号')
|
||||
->assertSeeText('对方 E 签宝账号')
|
||||
->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('对方 E 签宝实名认证姓名')
|
||||
->assertSeeText('仅用于 E 签宝身份核验,不是收款人姓名')
|
||||
->assertSeeText('合作合同统一使用我方账号 18052733960')
|
||||
|
||||
@ -49,6 +49,7 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase
|
||||
$this->assertSame(CourierAgreementSignature::STATUS_SIGNING, $signature->status);
|
||||
$this->assertSame('flow-agreement-001', $signature->esign_sign_flow_id);
|
||||
$this->assertSame('新北中通', $signature->organization_name);
|
||||
$this->assertSame('2026-09-21', $signature->payload['signature_date']);
|
||||
Storage::disk('uploads')->assertExists($signature->source_pdf_path);
|
||||
$this->assertSame('新北中通快递有限公司', $signature->payload['account_proof_account_name']);
|
||||
|
||||
@ -144,6 +145,7 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase
|
||||
'document_type' => 'cooperation-contract',
|
||||
'signing_subject' => $this->company->name,
|
||||
'company_full_name' => $this->company->name,
|
||||
'signature_date' => '2026-09-21',
|
||||
'signing_deadline' => now()->addDays(7)->toDateString(),
|
||||
'esign_account' => '13800138000',
|
||||
'secondary_esign_account' => '13900139000',
|
||||
@ -194,6 +196,7 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase
|
||||
'document_type' => 'shared-contract',
|
||||
'signing_subject' => $this->company->name,
|
||||
'company_full_name' => $this->company->name,
|
||||
'signature_date' => '2026-09-21',
|
||||
'signing_deadline' => now()->addDays(7)->toDateString(),
|
||||
'esign_account' => '13800138000',
|
||||
'operator_name' => '张经理',
|
||||
@ -275,6 +278,7 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase
|
||||
'quote_version_id' => $version->id,
|
||||
'signing_subject' => $this->company->name,
|
||||
'company_full_name' => $this->company->name,
|
||||
'signature_date' => '2026-09-21',
|
||||
'signing_deadline' => now()->addDays(7)->toDateString(),
|
||||
'esign_account' => '13800138000',
|
||||
'operator_name' => '李经理',
|
||||
@ -304,6 +308,79 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase
|
||||
});
|
||||
}
|
||||
|
||||
public function test_signature_date_is_required_for_every_signing_request(): void
|
||||
{
|
||||
$payload = $this->accountProofPayload('missing-signature-date');
|
||||
unset($payload['signature_date']);
|
||||
|
||||
$this->withExceptionHandling()->loginAs()
|
||||
->post(route('business.courier-agreement-signatures.store'), $payload)
|
||||
->assertSessionHasErrors('signature_date');
|
||||
|
||||
$this->assertDatabaseCount('business_courier_agreement_signatures', 0);
|
||||
}
|
||||
|
||||
public function test_all_agreement_templates_render_the_selected_signature_date(): void
|
||||
{
|
||||
$payload = [
|
||||
'company_full_name' => $this->company->name,
|
||||
'signature_date' => '2026-09-21',
|
||||
'account_proof_effective_date' => '2026-09-01',
|
||||
'account_proof_account_name' => '测试收款人',
|
||||
'account_proof_bank_name' => '测试银行',
|
||||
'account_proof_bank_account' => '6222000012345678',
|
||||
'account_change_date' => '2026-09-01',
|
||||
'old_account_name' => '旧账户',
|
||||
'old_account_number' => '6222000011111111',
|
||||
'old_account_stop_date' => '2026-09-01',
|
||||
'new_account_effective_date' => '2026-09-02',
|
||||
'new_account_name' => '新账户',
|
||||
'new_bank_name' => '新银行',
|
||||
'new_bank_account' => '6222000022222222',
|
||||
'former_company_name' => '变更前公司',
|
||||
'entity_change_date' => '2026-09-01',
|
||||
'changed_company_name' => '变更后公司',
|
||||
'changed_company_start_date' => '2026-09-02',
|
||||
'contract_partner_name' => '快递公司甲',
|
||||
'shared_courier_names' => ['快递公司乙'],
|
||||
'cooperation_party_a_name' => '甲方公司',
|
||||
'cooperation_party_a_phone' => '0519-88886666',
|
||||
'cooperation_party_b_name' => '乙方公司',
|
||||
'cooperation_party_b_phone' => '0519-88889999',
|
||||
'cooperation_start_date' => '2026-09-01',
|
||||
'cooperation_end_date' => '2027-08-31',
|
||||
'principal_name' => '委托方',
|
||||
'principal_id_number' => '91320400TEST',
|
||||
'trustee_name' => '受托方',
|
||||
'trustee_id_number' => '320400TEST',
|
||||
'authorization_items' => ['1'],
|
||||
'bill_counterparty' => '对账方',
|
||||
'loan_counterparty' => '借支方',
|
||||
'payment_counterparty' => '付款方',
|
||||
'authorization_start_date' => '2026-09-01',
|
||||
'authorization_end_date' => '2027-08-31',
|
||||
];
|
||||
|
||||
foreach (['account-proof', 'account-change', 'entity-change', 'shared-contract', 'cooperation-contract', 'authorization'] as $type) {
|
||||
$signature = new CourierAgreementSignature([
|
||||
'business_no' => 'AGR-DATE-' . $type,
|
||||
'template_code' => CourierAgreementSignature::DOCUMENTS[$type]['code'],
|
||||
'document_type' => $type,
|
||||
]);
|
||||
$html = view('business.pdf.courier-agreement', [
|
||||
'signature' => $signature,
|
||||
'type' => $type,
|
||||
'title' => CourierAgreementSignature::DOCUMENTS[$type]['name'],
|
||||
'payload' => $payload,
|
||||
'signatureDate' => '2026年9月21日',
|
||||
'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')),
|
||||
])->render();
|
||||
|
||||
$this->assertStringContainsString('2026年9月21日', strip_tags($html), "{$type} 未输出签署日期变量");
|
||||
$this->assertStringNotContainsString('签署后生成', $html, "{$type} 仍包含旧占位文案");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_verified_agreement_callback_is_idempotent_and_queues_archive(): void
|
||||
{
|
||||
Queue::fake();
|
||||
@ -584,6 +661,7 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase
|
||||
'document_type' => 'account-proof',
|
||||
'signing_subject' => $this->company->name,
|
||||
'company_full_name' => $this->company->name,
|
||||
'signature_date' => '2026-09-21',
|
||||
'signing_deadline' => now()->addDays(7)->toDateString(),
|
||||
'esign_account' => '13800138000',
|
||||
'operator_name' => '张经理',
|
||||
|
||||
@ -6,6 +6,8 @@ use App\Business\CourierPayableSummaryStore;
|
||||
use App\Models\Auth\Permission;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class CourierPayableSummaryPagesTest extends FeatureTestCase
|
||||
@ -20,7 +22,7 @@ class CourierPayableSummaryPagesTest extends FeatureTestCase
|
||||
Storage::fake('uploads');
|
||||
}
|
||||
|
||||
public function test_final_workspace_places_both_summary_pages_after_courier_payables_report(): void
|
||||
public function test_final_workspace_places_summary_pages_after_courier_payables_report(): void
|
||||
{
|
||||
$response = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables-final',
|
||||
@ -30,6 +32,7 @@ class CourierPayableSummaryPagesTest extends FeatureTestCase
|
||||
route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-payables-report']),
|
||||
route('business.pages.show', ['section' => 'courier-payables', 'page' => 'freight-difference']),
|
||||
route('business.pages.show', ['section' => 'courier-payables', 'page' => 'other-surcharge-summary']),
|
||||
route('business.pages.show', ['section' => 'courier-payables', 'page' => 'return-fee']),
|
||||
], false);
|
||||
}
|
||||
|
||||
@ -45,10 +48,537 @@ class CourierPayableSummaryPagesTest extends FeatureTestCase
|
||||
->assertSeeText($title)
|
||||
->assertSeeText('导入')
|
||||
->assertSeeText('导出')
|
||||
->assertSee('name="date"', false)
|
||||
->assertSee('name="person"', false)
|
||||
->assertSee('name="courier"', false)
|
||||
->assertSee('name="keyword"', false)
|
||||
->assertSeeText('查询')
|
||||
->assertSeeText('重置')
|
||||
->assertSeeText('暂无数据,请先点击“导入”上传汇总表。');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_each_summary_page_can_query_by_date_person_courier_and_long_identifier(): void
|
||||
{
|
||||
$pages = [
|
||||
'freight-difference' => ['结算月份', '快递公司', 'SF000000000000000001'],
|
||||
'other-surcharge-summary' => ['日期', '快递公司', 'ORDER000000000000000002'],
|
||||
'return-fee' => ['日期', '快递名称', '7732543323146123456'],
|
||||
];
|
||||
|
||||
foreach ($pages as $page => [$dateHeader, $courierHeader, $longIdentifier]) {
|
||||
$this->storeSnapshot($page, [
|
||||
$dateHeader,
|
||||
'负责人',
|
||||
$courierHeader,
|
||||
'运单号码',
|
||||
'备注',
|
||||
], [
|
||||
['2026-07', '苏木', '测试中通', $longIdentifier, '需要复核'],
|
||||
['2026-08', '周生辰', '测试圆通', 'OTHER000000000000000009', '无需复核'],
|
||||
]);
|
||||
|
||||
$url = route('business.pages.show', [
|
||||
'company_id' => $this->company->id,
|
||||
'section' => 'courier-payables',
|
||||
'page' => $page,
|
||||
]) . '?' . http_build_query([
|
||||
'date' => '2026-07',
|
||||
'person' => '苏木',
|
||||
'courier' => '中通',
|
||||
'keyword' => $longIdentifier,
|
||||
]);
|
||||
|
||||
$this->loginAs()->get($url)
|
||||
->assertOk()
|
||||
->assertSeeText($longIdentifier)
|
||||
->assertDontSeeText('OTHER000000000000000009')
|
||||
->assertSeeText('查询到 1 条 / 共 2 条')
|
||||
->assertSee('value="2026-07"', false)
|
||||
->assertSee('value="苏木"', false)
|
||||
->assertSee('value="中通"', false)
|
||||
->assertSee('value="' . $longIdentifier . '"', false);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_summary_query_preserves_filters_during_pagination_and_has_a_clear_empty_state(): void
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
for ($index = 1; $index <= 51; $index++) {
|
||||
$rows[] = [
|
||||
'2026-07',
|
||||
'苏木',
|
||||
'测试中通',
|
||||
sprintf('SF%018d', $index),
|
||||
sprintf('ORDER%015d', $index),
|
||||
];
|
||||
}
|
||||
|
||||
$this->storeSnapshot('return-fee', ['日期', '负责人', '快递名称', '运单号码', '交易订单'], $rows);
|
||||
$pageRoute = route('business.pages.show', [
|
||||
'company_id' => $this->company->id,
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'return-fee',
|
||||
]);
|
||||
$filters = ['courier' => '中通', 'keyword' => 'ORDER'];
|
||||
|
||||
$this->loginAs()->get($pageRoute . '?' . http_build_query($filters))
|
||||
->assertOk()
|
||||
->assertSeeText('第 1 / 2 页')
|
||||
->assertSee('courier=' . rawurlencode('中通') . '&keyword=ORDER&page=2', false);
|
||||
|
||||
$lastWaybill = sprintf('SF%018d', 51);
|
||||
$this->loginAs()->get($pageRoute . '?' . http_build_query([...$filters, 'page' => 2]))
|
||||
->assertOk()
|
||||
->assertSeeText($lastWaybill)
|
||||
->assertSeeText('第 2 / 2 页');
|
||||
|
||||
$this->loginAs()->get($pageRoute . '?keyword=' . rawurlencode('完全不存在的数据'))
|
||||
->assertOk()
|
||||
->assertSeeText('当前查询条件下没有匹配数据。')
|
||||
->assertDontSeeText('暂无数据,请先点击“导入”上传汇总表。')
|
||||
->assertSeeText('查询到 0 条 / 共 51 条');
|
||||
}
|
||||
|
||||
public function test_each_summary_page_exports_only_filtered_rows_and_preserves_year_month_display(): void
|
||||
{
|
||||
$pages = [
|
||||
'freight-difference' => ['结算月份', '快递公司'],
|
||||
'other-surcharge-summary' => ['日期', '快递公司'],
|
||||
'return-fee' => ['日期', '快递名称'],
|
||||
];
|
||||
|
||||
foreach ($pages as $page => [$dateHeader, $courierHeader]) {
|
||||
$this->storeSnapshot($page, [
|
||||
$dateHeader,
|
||||
'负责人',
|
||||
$courierHeader,
|
||||
'运单号码',
|
||||
'备注',
|
||||
], [
|
||||
['2026-07', '苏木', '测试中通', 'SF000000000000000001', '需要导出'],
|
||||
['2026-08', '苏木', '测试中通', 'SF000000000000000002', '其他月份'],
|
||||
['2026-07', '周生辰', '测试圆通', 'SF000000000000000003', '其他负责人'],
|
||||
]);
|
||||
|
||||
$filters = [
|
||||
'date' => '2026-07',
|
||||
'person' => '苏木',
|
||||
'courier' => '中通',
|
||||
'keyword' => '需要导出',
|
||||
];
|
||||
$exportUrl = route('business.courier-payable-summaries.export', [
|
||||
'company_id' => $this->company->id,
|
||||
'summaryPage' => $page,
|
||||
...$filters,
|
||||
]);
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'company_id' => $this->company->id,
|
||||
'section' => 'courier-payables',
|
||||
'page' => $page,
|
||||
...$filters,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSee(htmlspecialchars($exportUrl, ENT_QUOTES, 'UTF-8'), false);
|
||||
|
||||
$response = $this->loginAs()->get($exportUrl);
|
||||
$content = $response->streamedContent();
|
||||
$lines = preg_split('/\r\n|\r|\n/', trim(substr($content, 3)));
|
||||
|
||||
$response->assertOk()->assertHeader('content-type', 'text/csv; charset=UTF-8');
|
||||
$this->assertCount(2, $lines);
|
||||
$this->assertSame('="2026-07"', str_getcsv($lines[1])[0]);
|
||||
$this->assertStringContainsString('SF000000000000000001', $content);
|
||||
$this->assertStringNotContainsString('SF000000000000000002', $content);
|
||||
$this->assertStringNotContainsString('SF000000000000000003', $content);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_freight_difference_import_dialog_downloads_the_supplied_xlsx_template(): void
|
||||
{
|
||||
$pageResponse = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'freight-difference',
|
||||
]));
|
||||
|
||||
$pageResponse
|
||||
->assertOk()
|
||||
->assertSee('data-courier-summary-import-dialog', false)
|
||||
->assertSeeText('下载模板')
|
||||
->assertSee(route('business.courier-payable-summaries.template', [
|
||||
'summaryPage' => 'freight-difference',
|
||||
]), false);
|
||||
|
||||
$templateResponse = $this->loginAs()->get(route('business.courier-payable-summaries.template', [
|
||||
'summaryPage' => 'freight-difference',
|
||||
]));
|
||||
|
||||
$templateResponse
|
||||
->assertOk()
|
||||
->assertDownload('freight_difference_import_template.xlsx')
|
||||
->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$this->assertStringContainsString(
|
||||
"filename*=utf-8''" . rawurlencode('运费差导入模板.xlsx'),
|
||||
(string) $templateResponse->headers->get('Content-Disposition'),
|
||||
);
|
||||
|
||||
$spreadsheet = IOFactory::load(resource_path('templates/courier-payables/freight-difference-import-template.xlsx'));
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
$this->assertSame([
|
||||
'日期',
|
||||
'负责人',
|
||||
'快递公司',
|
||||
'快递单号',
|
||||
'店铺',
|
||||
'id',
|
||||
'条码',
|
||||
'交易单号',
|
||||
'差异(结算运费-提供成本运费)',
|
||||
'是否配件',
|
||||
'配件成品条码',
|
||||
], $worksheet->rangeToArray('A1:K1', null, false, false)[0]);
|
||||
$this->assertSame('FFFFC000', $worksheet->getStyle('E1')->getFill()->getStartColor()->getARGB());
|
||||
$this->assertSame('FFFFC000', $worksheet->getStyle('F1')->getFill()->getStartColor()->getARGB());
|
||||
$this->assertSame('FFFFC000', $worksheet->getStyle('H1')->getFill()->getStartColor()->getARGB());
|
||||
$this->assertSame('@', $worksheet->getStyle('A2')->getNumberFormat()->getFormatCode());
|
||||
$this->assertSame('@', $worksheet->getStyle('F2')->getNumberFormat()->getFormatCode());
|
||||
$this->assertSame('@', $worksheet->getStyle('H2')->getNumberFormat()->getFormatCode());
|
||||
$this->assertSame('', (string) $worksheet->getCell('A2')->getValue());
|
||||
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
|
||||
public function test_other_surcharge_import_dialog_downloads_the_supplied_xlsx_template(): void
|
||||
{
|
||||
$pageResponse = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'other-surcharge-summary',
|
||||
]));
|
||||
|
||||
$pageResponse
|
||||
->assertOk()
|
||||
->assertSee('data-courier-summary-import-dialog', false)
|
||||
->assertSeeText('导入其他附加费汇总')
|
||||
->assertSeeText('其他附加费导入模板')
|
||||
->assertSeeText('.xlsx · 11 个字段')
|
||||
->assertSeeText('下载模板')
|
||||
->assertSee(route('business.courier-payable-summaries.template', [
|
||||
'summaryPage' => 'other-surcharge-summary',
|
||||
]), false);
|
||||
|
||||
$templateResponse = $this->loginAs()->get(route('business.courier-payable-summaries.template', [
|
||||
'summaryPage' => 'other-surcharge-summary',
|
||||
]));
|
||||
|
||||
$templateResponse
|
||||
->assertOk()
|
||||
->assertDownload('other_surcharge_import_template.xlsx')
|
||||
->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$this->assertStringContainsString(
|
||||
"filename*=utf-8''" . rawurlencode('其他附加费导入模板.xlsx'),
|
||||
(string) $templateResponse->headers->get('Content-Disposition'),
|
||||
);
|
||||
|
||||
$spreadsheet = IOFactory::load(resource_path('templates/courier-payables/other-surcharge-import-template.xlsx'));
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
$this->assertSame([
|
||||
'日期',
|
||||
'负责人',
|
||||
'快递公司',
|
||||
'快递单号',
|
||||
'商品ID',
|
||||
'条码',
|
||||
'店铺',
|
||||
'交易单号',
|
||||
'金额',
|
||||
'备注',
|
||||
'成品条码',
|
||||
], $worksheet->rangeToArray('A1:K1', null, false, false)[0]);
|
||||
$this->assertSame('@', $worksheet->getStyle('A2')->getNumberFormat()->getFormatCode());
|
||||
$this->assertSame('@', $worksheet->getStyle('D2')->getNumberFormat()->getFormatCode());
|
||||
$this->assertSame('@', $worksheet->getStyle('E2')->getNumberFormat()->getFormatCode());
|
||||
$this->assertSame('@', $worksheet->getStyle('F2')->getNumberFormat()->getFormatCode());
|
||||
$this->assertSame('@', $worksheet->getStyle('H2')->getNumberFormat()->getFormatCode());
|
||||
$this->assertSame('0.00########', $worksheet->getStyle('I2')->getNumberFormat()->getFormatCode());
|
||||
$this->assertSame('@', $worksheet->getStyle('K2')->getNumberFormat()->getFormatCode());
|
||||
$this->assertSame('', (string) $worksheet->getCell('A2')->getValue());
|
||||
$this->assertSame('', (string) $worksheet->getCell('K18')->getValue());
|
||||
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
|
||||
public function test_return_fee_import_dialog_downloads_the_supplied_xlsx_template(): void
|
||||
{
|
||||
$pageResponse = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'return-fee',
|
||||
]));
|
||||
|
||||
$pageResponse
|
||||
->assertOk()
|
||||
->assertSee('data-courier-summary-import-dialog', false)
|
||||
->assertSeeText('导入退件费')
|
||||
->assertSeeText('退件费导入模板')
|
||||
->assertSeeText('.xlsx · 11 个字段')
|
||||
->assertSeeText('下载模板')
|
||||
->assertSee(route('business.courier-payable-summaries.template', [
|
||||
'summaryPage' => 'return-fee',
|
||||
]), false);
|
||||
|
||||
$templateResponse = $this->loginAs()->get(route('business.courier-payable-summaries.template', [
|
||||
'summaryPage' => 'return-fee',
|
||||
]));
|
||||
|
||||
$templateResponse
|
||||
->assertOk()
|
||||
->assertDownload('return_fee_import_template.xlsx')
|
||||
->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$this->assertStringContainsString(
|
||||
"filename*=utf-8''" . rawurlencode('退件费导入模板.xlsx'),
|
||||
(string) $templateResponse->headers->get('Content-Disposition'),
|
||||
);
|
||||
|
||||
$spreadsheet = IOFactory::load(resource_path('templates/courier-payables/return-fee-import-template.xlsx'));
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
$this->assertSame([
|
||||
'日期',
|
||||
'负责人',
|
||||
'快递名称',
|
||||
'运单号码',
|
||||
'ID',
|
||||
'条码',
|
||||
'店铺',
|
||||
'交易订单',
|
||||
'结算金额',
|
||||
'备注',
|
||||
'成品条码',
|
||||
], $worksheet->rangeToArray('A1:K1', null, false, false)[0]);
|
||||
|
||||
foreach (['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K'] as $column) {
|
||||
$this->assertSame('@', $worksheet->getStyle($column . '2')->getNumberFormat()->getFormatCode());
|
||||
}
|
||||
$this->assertSame('0.00########', $worksheet->getStyle('I2')->getNumberFormat()->getFormatCode());
|
||||
|
||||
foreach ($worksheet->rangeToArray('A2:K15', null, false, false) as $row) {
|
||||
$this->assertSame([], array_values(array_filter(
|
||||
$row,
|
||||
static fn (mixed $value): bool => $value !== null && $value !== '',
|
||||
)));
|
||||
}
|
||||
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
|
||||
public function test_completed_freight_difference_template_preserves_long_identifiers_on_import(): void
|
||||
{
|
||||
$spreadsheet = IOFactory::load(resource_path('templates/courier-payables/freight-difference-import-template.xlsx'));
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
$worksheet->fromArray([
|
||||
'2026-07',
|
||||
'苏木',
|
||||
'测试快递',
|
||||
'YT7632543323146',
|
||||
'测试店铺',
|
||||
'855226246304',
|
||||
'DNZ0117123N1210V01',
|
||||
'3312228147193110069',
|
||||
0.1827,
|
||||
'成品',
|
||||
'',
|
||||
], null, 'A2');
|
||||
$worksheet->getCell('F2')->setValueExplicit('855226246304', DataType::TYPE_STRING);
|
||||
$worksheet->getCell('H2')->setValueExplicit('3312228147193110069', DataType::TYPE_STRING);
|
||||
|
||||
$path = tempnam(sys_get_temp_dir(), 'freight-template-');
|
||||
$this->assertNotFalse($path);
|
||||
$xlsxPath = $path . '.xlsx';
|
||||
|
||||
try {
|
||||
IOFactory::createWriter($spreadsheet, 'Xlsx')->save($xlsxPath);
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
|
||||
$file = new UploadedFile(
|
||||
$xlsxPath,
|
||||
'运费差导入模板.xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
null,
|
||||
true,
|
||||
);
|
||||
|
||||
$this->loginAs()->post(route('business.courier-payable-summaries.import', [
|
||||
'summaryPage' => 'freight-difference',
|
||||
]), ['import' => $file])
|
||||
->assertRedirect(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'freight-difference',
|
||||
]))
|
||||
->assertSessionHas('success', '导入完成:1 条数据,11 个字段。');
|
||||
|
||||
$snapshot = app(CourierPayableSummaryStore::class)->snapshot(
|
||||
(int) $this->company->id,
|
||||
'freight-difference',
|
||||
);
|
||||
$this->assertSame('2026-07', $snapshot['rows'][0][0]);
|
||||
$this->assertSame('855226246304', $snapshot['rows'][0][5]);
|
||||
$this->assertSame('3312228147193110069', $snapshot['rows'][0][7]);
|
||||
$this->assertSame('0.1827', $snapshot['rows'][0][8]);
|
||||
} finally {
|
||||
if (isset($spreadsheet)) {
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
if ($path !== false && is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
if (isset($xlsxPath) && is_file($xlsxPath)) {
|
||||
unlink($xlsxPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_completed_other_surcharge_template_preserves_long_identifiers_on_import(): void
|
||||
{
|
||||
$spreadsheet = IOFactory::load(resource_path('templates/courier-payables/other-surcharge-import-template.xlsx'));
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
$worksheet->fromArray([
|
||||
'2026-07',
|
||||
'苏木',
|
||||
'测试快递',
|
||||
'SF5199125637478',
|
||||
'6041543429417',
|
||||
'SJ038017N1410V01',
|
||||
'测试店铺',
|
||||
'5110751415866048404',
|
||||
-60,
|
||||
'安装费',
|
||||
'CP001',
|
||||
], null, 'A2');
|
||||
$worksheet->getCell('E2')->setValueExplicit('6041543429417', DataType::TYPE_STRING);
|
||||
$worksheet->getCell('H2')->setValueExplicit('5110751415866048404', DataType::TYPE_STRING);
|
||||
|
||||
$path = tempnam(sys_get_temp_dir(), 'other-surcharge-template-');
|
||||
$this->assertNotFalse($path);
|
||||
$xlsxPath = $path . '.xlsx';
|
||||
|
||||
try {
|
||||
IOFactory::createWriter($spreadsheet, 'Xlsx')->save($xlsxPath);
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
|
||||
$file = new UploadedFile(
|
||||
$xlsxPath,
|
||||
'其他附加费导入模板.xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
null,
|
||||
true,
|
||||
);
|
||||
|
||||
$this->loginAs()->post(route('business.courier-payable-summaries.import', [
|
||||
'summaryPage' => 'other-surcharge-summary',
|
||||
]), ['import' => $file])
|
||||
->assertRedirect(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'other-surcharge-summary',
|
||||
]))
|
||||
->assertSessionHas('success', '导入完成:1 条数据,11 个字段。');
|
||||
|
||||
$snapshot = app(CourierPayableSummaryStore::class)->snapshot(
|
||||
(int) $this->company->id,
|
||||
'other-surcharge-summary',
|
||||
);
|
||||
$this->assertSame('2026-07', $snapshot['rows'][0][0]);
|
||||
$this->assertSame('6041543429417', $snapshot['rows'][0][4]);
|
||||
$this->assertSame('5110751415866048404', $snapshot['rows'][0][7]);
|
||||
$this->assertSame('-60', $snapshot['rows'][0][8]);
|
||||
$this->assertSame('安装费', $snapshot['rows'][0][9]);
|
||||
} finally {
|
||||
if (isset($spreadsheet)) {
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
if ($path !== false && is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
if (isset($xlsxPath) && is_file($xlsxPath)) {
|
||||
unlink($xlsxPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_completed_return_fee_template_preserves_long_identifiers_and_amount_precision(): void
|
||||
{
|
||||
$spreadsheet = IOFactory::load(resource_path('templates/courier-payables/return-fee-import-template.xlsx'));
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
$worksheet->fromArray([
|
||||
'2026-09-20',
|
||||
'苏木',
|
||||
'测试快递',
|
||||
'7732543323146123456',
|
||||
'6041543429417123456',
|
||||
'SJ038017N1410V01',
|
||||
'测试店铺',
|
||||
'5110751415866048404',
|
||||
12.3456,
|
||||
'退件费',
|
||||
'CP000000000000001',
|
||||
], null, 'A2');
|
||||
$worksheet->getCell('D2')->setValueExplicit('7732543323146123456', DataType::TYPE_STRING);
|
||||
$worksheet->getCell('E2')->setValueExplicit('6041543429417123456', DataType::TYPE_STRING);
|
||||
$worksheet->getCell('F2')->setValueExplicit('SJ038017N1410V01', DataType::TYPE_STRING);
|
||||
$worksheet->getCell('H2')->setValueExplicit('5110751415866048404', DataType::TYPE_STRING);
|
||||
$worksheet->getCell('K2')->setValueExplicit('CP000000000000001', DataType::TYPE_STRING);
|
||||
|
||||
$path = tempnam(sys_get_temp_dir(), 'return-fee-template-');
|
||||
$this->assertNotFalse($path);
|
||||
$xlsxPath = $path . '.xlsx';
|
||||
|
||||
try {
|
||||
IOFactory::createWriter($spreadsheet, 'Xlsx')->save($xlsxPath);
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
|
||||
$file = new UploadedFile(
|
||||
$xlsxPath,
|
||||
'退件费导入模板.xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
null,
|
||||
true,
|
||||
);
|
||||
|
||||
$this->loginAs()->post(route('business.courier-payable-summaries.import', [
|
||||
'summaryPage' => 'return-fee',
|
||||
]), ['import' => $file])
|
||||
->assertRedirect(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'return-fee',
|
||||
]))
|
||||
->assertSessionHas('success', '导入完成:1 条数据,11 个字段。');
|
||||
|
||||
$snapshot = app(CourierPayableSummaryStore::class)->snapshot(
|
||||
(int) $this->company->id,
|
||||
'return-fee',
|
||||
);
|
||||
$this->assertSame('2026-09-20', $snapshot['rows'][0][0]);
|
||||
$this->assertSame('7732543323146123456', $snapshot['rows'][0][3]);
|
||||
$this->assertSame('6041543429417123456', $snapshot['rows'][0][4]);
|
||||
$this->assertSame('SJ038017N1410V01', $snapshot['rows'][0][5]);
|
||||
$this->assertSame('5110751415866048404', $snapshot['rows'][0][7]);
|
||||
$this->assertSame('12.3456', $snapshot['rows'][0][8]);
|
||||
$this->assertSame('CP000000000000001', $snapshot['rows'][0][10]);
|
||||
} finally {
|
||||
if (isset($spreadsheet)) {
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
if ($path !== false && is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
if (isset($xlsxPath) && is_file($xlsxPath)) {
|
||||
unlink($xlsxPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_import_replaces_the_page_snapshot_and_export_returns_all_rows(): void
|
||||
{
|
||||
$file = UploadedFile::fake()->createWithContent(
|
||||
@ -86,10 +616,85 @@ class CourierPayableSummaryPagesTest extends FeatureTestCase
|
||||
|
||||
$response->assertOk()->assertHeader('content-type', 'text/csv; charset=UTF-8');
|
||||
$this->assertStringContainsString('快递公司,结算月份,运费差,备注', $content);
|
||||
$this->assertStringContainsString('测试快递,2026-08,-18.50,复核', $content);
|
||||
$this->assertStringContainsString('测试快递,"=""2026-08""",-18.50,复核', $content);
|
||||
$this->assertStringContainsString("\t=待确认", $content);
|
||||
}
|
||||
|
||||
public function test_return_fee_page_paginates_and_export_includes_all_imported_rows(): void
|
||||
{
|
||||
$rows = ["运单号码,交易订单,结算金额\n"];
|
||||
for ($index = 1; $index <= 51; $index++) {
|
||||
$rows[] = sprintf("SF%018d,ORDER%015d,%d.%04d\n", $index, $index, $index, $index);
|
||||
}
|
||||
|
||||
$file = UploadedFile::fake()->createWithContent('退件费.csv', implode('', $rows));
|
||||
$this->loginAs()->post(route('business.courier-payable-summaries.import', [
|
||||
'summaryPage' => 'return-fee',
|
||||
]), ['import' => $file])
|
||||
->assertRedirect(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'return-fee',
|
||||
]))
|
||||
->assertSessionHas('success', '导入完成:51 条数据,3 个字段。');
|
||||
|
||||
$firstWaybill = sprintf('SF%018d', 1);
|
||||
$lastWaybill = sprintf('SF%018d', 51);
|
||||
$pageRoute = route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'return-fee',
|
||||
]);
|
||||
|
||||
$this->loginAs()->get($pageRoute)
|
||||
->assertOk()
|
||||
->assertSeeText($firstWaybill)
|
||||
->assertDontSeeText($lastWaybill)
|
||||
->assertSeeText('第 1 / 2 页');
|
||||
$this->loginAs()->get($pageRoute . '?page=2')
|
||||
->assertOk()
|
||||
->assertSeeText($lastWaybill)
|
||||
->assertSeeText('第 2 / 2 页');
|
||||
|
||||
$response = $this->loginAs()->get(route('business.courier-payable-summaries.export', [
|
||||
'summaryPage' => 'return-fee',
|
||||
]));
|
||||
$content = $response->streamedContent();
|
||||
|
||||
$response->assertOk()->assertHeader('content-type', 'text/csv; charset=UTF-8');
|
||||
$this->assertStringContainsString($firstWaybill, $content);
|
||||
$this->assertStringContainsString($lastWaybill, $content);
|
||||
$this->assertStringContainsString('51.0051', $content);
|
||||
}
|
||||
|
||||
public function test_summary_imports_accept_more_than_ten_thousand_rows(): void
|
||||
{
|
||||
$store = app(CourierPayableSummaryStore::class);
|
||||
$this->assertSame(500000, $store->maxRows('freight-difference'));
|
||||
$this->assertSame(500000, $store->maxRows('other-surcharge-summary'));
|
||||
$this->assertSame(500000, $store->maxRows('return-fee'));
|
||||
|
||||
$rows = ["快递公司,结算月份\n"];
|
||||
for ($index = 1; $index <= 10001; $index++) {
|
||||
$rows[] = "测试快递,2026-08\n";
|
||||
}
|
||||
|
||||
foreach (['freight-difference', 'other-surcharge-summary', 'return-fee'] as $page) {
|
||||
$file = UploadedFile::fake()->createWithContent($page . '.csv', implode('', $rows));
|
||||
|
||||
$this->loginAs()->post(route('business.courier-payable-summaries.import', [
|
||||
'summaryPage' => $page,
|
||||
]), ['import' => $file])
|
||||
->assertRedirect(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => $page,
|
||||
]))
|
||||
->assertSessionHas('success', '导入完成:10001 条数据,2 个字段。')
|
||||
->assertSessionDoesntHaveErrors('import');
|
||||
|
||||
$snapshot = $store->snapshot((int) $this->company->id, $page);
|
||||
$this->assertCount(10001, $snapshot['rows']);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_summary_actions_require_the_matching_page_permission(): void
|
||||
{
|
||||
$this->withExceptionHandling();
|
||||
@ -104,5 +709,42 @@ class CourierPayableSummaryPagesTest extends FeatureTestCase
|
||||
$this->loginAs()->get(route('business.courier-payable-summaries.export', [
|
||||
'summaryPage' => 'freight-difference',
|
||||
]))->assertForbidden();
|
||||
|
||||
$this->loginAs()->get(route('business.courier-payable-summaries.template', [
|
||||
'summaryPage' => 'freight-difference',
|
||||
]))->assertForbidden();
|
||||
|
||||
$this->loginAs()->get(route('business.courier-payable-summaries.template', [
|
||||
'summaryPage' => 'other-surcharge-summary',
|
||||
]))->assertForbidden();
|
||||
|
||||
$this->loginAs()->post(route('business.courier-payable-summaries.import', [
|
||||
'summaryPage' => 'return-fee',
|
||||
]))->assertForbidden();
|
||||
|
||||
$this->loginAs()->get(route('business.courier-payable-summaries.export', [
|
||||
'summaryPage' => 'return-fee',
|
||||
]))->assertForbidden();
|
||||
|
||||
$this->loginAs()->get(route('business.courier-payable-summaries.template', [
|
||||
'summaryPage' => 'return-fee',
|
||||
]))->assertForbidden();
|
||||
}
|
||||
|
||||
/** @param list<string> $headers @param list<list<string>> $rows */
|
||||
private function storeSnapshot(string $page, array $headers, array $rows): void
|
||||
{
|
||||
$store = app(CourierPayableSummaryStore::class);
|
||||
Storage::disk('uploads')->put(
|
||||
$store->path((int) $this->company->id, $page),
|
||||
json_encode([
|
||||
'version' => 1,
|
||||
'headers' => $headers,
|
||||
'rows' => $rows,
|
||||
'source_name' => $page . '.xlsx',
|
||||
'imported_at' => now()->toIso8601String(),
|
||||
'imported_by' => $this->user->id,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,6 +46,26 @@ class CourierQuoteSignatureWorkflowTest extends FeatureTestCase
|
||||
$again = app(CourierQuoteSignatureService::class)->prepareVersion($version->fresh(), $this->user->id);
|
||||
$this->assertSame($signature->id, $again->id);
|
||||
$this->assertSame($signature->source_pdf_sha256, $again->source_pdf_sha256);
|
||||
|
||||
$dated = app(CourierQuoteSignatureService::class)->prepareVersion($version->fresh(), $this->user->id, '2026-09-21');
|
||||
$this->assertSame($signature->id, $dated->id);
|
||||
$this->assertNotSame($signature->source_pdf_sha256, $dated->source_pdf_sha256);
|
||||
|
||||
$html = view('business.pdf.courier-quote', [
|
||||
'version' => $version,
|
||||
'change' => $version->change,
|
||||
'values' => [['省份', '面单'], ['广东省', 6]],
|
||||
'formulas' => [[], []],
|
||||
'partyA' => $this->company->name,
|
||||
'expiryDate' => '长期',
|
||||
'faceFee' => '6',
|
||||
'bubbleCoefficient' => '1.2',
|
||||
'signatureDate' => '2026年9月21日',
|
||||
'generatedAt' => now(),
|
||||
'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')),
|
||||
])->render();
|
||||
$this->assertStringContainsString('签署日期:', $html);
|
||||
$this->assertStringContainsString('2026年9月21日', strip_tags($html));
|
||||
}
|
||||
|
||||
public function test_admin_can_push_the_exact_pdf_to_esign_sandbox(): void
|
||||
@ -75,6 +95,7 @@ class CourierQuoteSignatureWorkflowTest extends FeatureTestCase
|
||||
'operator_name' => '张经理',
|
||||
'operator_account' => '13800138000',
|
||||
'notice_type' => '1',
|
||||
'signature_date' => '2026-09-21',
|
||||
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
|
||||
])->assertRedirect(route('business.courier-quote-changes.show', $signature->version->source_change_id));
|
||||
|
||||
@ -115,16 +136,41 @@ class CourierQuoteSignatureWorkflowTest extends FeatureTestCase
|
||||
Storage::disk('uploads')->put($signature->source_pdf_path, '%PDF-tampered');
|
||||
Http::fake();
|
||||
|
||||
try {
|
||||
app(CourierQuoteSignatureService::class)->send($signature, [
|
||||
'organization_name' => '新北中通快递有限公司',
|
||||
'operator_name' => '张经理',
|
||||
'operator_account' => '13800138000',
|
||||
'notice_type' => '1',
|
||||
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
|
||||
], $this->user->id);
|
||||
$this->fail('A tampered source PDF must not be sent.');
|
||||
} catch (\Illuminate\Validation\ValidationException $exception) {
|
||||
$this->assertSame(
|
||||
'待签报价 PDF 缺失或校验不一致,请重新生成后再推送。',
|
||||
$exception->errors()['signature'][0],
|
||||
);
|
||||
}
|
||||
|
||||
Http::assertNothingSent();
|
||||
$this->assertNull($signature->fresh()->esign_sign_flow_id);
|
||||
}
|
||||
|
||||
public function test_quote_send_requires_a_signature_date(): void
|
||||
{
|
||||
$signature = $this->createPreparedSignature();
|
||||
Http::fake();
|
||||
|
||||
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-signatures.send', $signature), [
|
||||
'organization_name' => '新北中通快递有限公司',
|
||||
'operator_name' => '张经理',
|
||||
'operator_account' => '13800138000',
|
||||
'notice_type' => '1',
|
||||
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
|
||||
])->assertSessionHasErrors('signature');
|
||||
])->assertSessionHasErrors('signature_date');
|
||||
|
||||
Http::assertNothingSent();
|
||||
$this->assertNull($signature->fresh()->esign_sign_flow_id);
|
||||
$this->assertSame(CourierQuoteSignature::STATUS_PREPARED, $signature->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_source_pdf_cannot_be_regenerated_after_signing_has_started(): void
|
||||
@ -175,6 +221,7 @@ class CourierQuoteSignatureWorkflowTest extends FeatureTestCase
|
||||
'operator_name' => '张经理',
|
||||
'operator_account' => '13800138000',
|
||||
'notice_type' => '1',
|
||||
'signature_date' => '2026-09-21',
|
||||
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
|
||||
])
|
||||
->assertForbidden();
|
||||
|
||||
@ -2,10 +2,12 @@
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Models\Business\CourierSurchargeType;
|
||||
use App\Models\Business\CourierSurchargeQuote;
|
||||
use App\Business\CourierSurchargeTypeRepository;
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Models\Business\CourierSurchargeAuditLog;
|
||||
use App\Models\Business\CourierSurchargeQuote;
|
||||
use App\Models\Business\CourierSurchargeType;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class CourierSurchargePageTest extends FeatureTestCase
|
||||
@ -211,6 +213,152 @@ class CourierSurchargePageTest extends FeatureTestCase
|
||||
$this->assertSame(CourierSurchargeQuote::STATUS_INACTIVE, $quote->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_quote_operations_are_recorded_with_actor_and_changed_fields(): void
|
||||
{
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$this->loginAs();
|
||||
|
||||
$payload = [
|
||||
'courier' => '日志测试快递',
|
||||
'scope' => '自有快递',
|
||||
'reconciler' => '周会计',
|
||||
'types' => ['进仓费'],
|
||||
'mode' => '固定单价',
|
||||
'standard' => '10 元/单',
|
||||
'condition' => '完成进仓',
|
||||
'effective_at' => '2026-09-21',
|
||||
'remark' => '初始标准',
|
||||
];
|
||||
|
||||
$this->post(route('business.courier-surcharge.store'), $payload)->assertRedirect();
|
||||
$quote = CourierSurchargeQuote::query()->where('courier', '日志测试快递')->firstOrFail();
|
||||
|
||||
$created = CourierSurchargeAuditLog::query()
|
||||
->where('target_type', CourierSurchargeAuditLog::TARGET_QUOTE)
|
||||
->where('target_id', $quote->id)
|
||||
->where('action', CourierSurchargeAuditLog::ACTION_CREATED)
|
||||
->firstOrFail();
|
||||
$this->assertSame((int) user_id(), (int) $created->actor_id);
|
||||
$this->assertSame('日志测试快递 · 进仓费', $created->target_label);
|
||||
$this->assertSame('10 元/单', data_get($created->changes, 'after.standard'));
|
||||
|
||||
$this->patch(route('business.courier-surcharge.update', ['quote' => $quote->id]), [
|
||||
...$payload,
|
||||
'standard' => '12 元/单',
|
||||
'remark' => '调整后标准',
|
||||
])->assertRedirect();
|
||||
|
||||
$updated = CourierSurchargeAuditLog::query()
|
||||
->where('target_type', CourierSurchargeAuditLog::TARGET_QUOTE)
|
||||
->where('target_id', $quote->id)
|
||||
->where('action', CourierSurchargeAuditLog::ACTION_UPDATED)
|
||||
->firstOrFail();
|
||||
$this->assertSame('10 元/单', data_get($updated->changes, 'before.standard'));
|
||||
$this->assertSame('12 元/单', data_get($updated->changes, 'after.standard'));
|
||||
$this->assertSame('初始标准', data_get($updated->changes, 'before.remark'));
|
||||
$this->assertSame('调整后标准', data_get($updated->changes, 'after.remark'));
|
||||
|
||||
$this->post(route('business.courier-surcharge.status', ['quote' => $quote->id]), [
|
||||
'status' => CourierSurchargeQuote::STATUS_INACTIVE,
|
||||
])->assertRedirect();
|
||||
$this->post(route('business.courier-surcharge.status', ['quote' => $quote->id]), [
|
||||
'status' => CourierSurchargeQuote::STATUS_ACTIVE,
|
||||
])->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('business_courier_surcharge_audit_logs', [
|
||||
'company_id' => company_id(),
|
||||
'target_type' => CourierSurchargeAuditLog::TARGET_QUOTE,
|
||||
'target_id' => $quote->id,
|
||||
'action' => CourierSurchargeAuditLog::ACTION_DEACTIVATED,
|
||||
]);
|
||||
$this->assertDatabaseHas('business_courier_surcharge_audit_logs', [
|
||||
'company_id' => company_id(),
|
||||
'target_type' => CourierSurchargeAuditLog::TARGET_QUOTE,
|
||||
'target_id' => $quote->id,
|
||||
'action' => CourierSurchargeAuditLog::ACTION_ACTIVATED,
|
||||
]);
|
||||
|
||||
$this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge']))
|
||||
->assertOk()
|
||||
->assertSeeText('操作日志')
|
||||
->assertSeeText('新增')
|
||||
->assertSeeText('更改')
|
||||
->assertSeeText('停用')
|
||||
->assertSeeText('启用')
|
||||
->assertSeeText($this->user->name)
|
||||
->assertSeeText('计价标准:10 元/单 → 12 元/单');
|
||||
}
|
||||
|
||||
public function test_type_operations_are_recorded_and_other_company_logs_are_hidden(): void
|
||||
{
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$this->loginAs();
|
||||
|
||||
$this->post(route('business.courier-surcharge.types.store'), ['name' => '日志专用类型'])
|
||||
->assertRedirect();
|
||||
$type = CourierSurchargeType::query()->where('name', '日志专用类型')->firstOrFail();
|
||||
|
||||
$this->post(route('business.courier-surcharge.types.status', ['type' => $type->id]), [
|
||||
'status' => CourierSurchargeType::STATUS_INACTIVE,
|
||||
])->assertRedirect();
|
||||
$this->post(route('business.courier-surcharge.types.status', ['type' => $type->id]), [
|
||||
'status' => CourierSurchargeType::STATUS_ACTIVE,
|
||||
])->assertRedirect();
|
||||
|
||||
$this->assertSame([
|
||||
CourierSurchargeAuditLog::ACTION_CREATED,
|
||||
CourierSurchargeAuditLog::ACTION_DEACTIVATED,
|
||||
CourierSurchargeAuditLog::ACTION_ACTIVATED,
|
||||
], CourierSurchargeAuditLog::query()
|
||||
->where('target_type', CourierSurchargeAuditLog::TARGET_TYPE)
|
||||
->where('target_id', $type->id)
|
||||
->orderBy('id')
|
||||
->pluck('action')
|
||||
->all());
|
||||
|
||||
CourierSurchargeAuditLog::withoutGlobalScopes()->create([
|
||||
'company_id' => company_id() + 999,
|
||||
'actor_id' => user_id(),
|
||||
'action' => CourierSurchargeAuditLog::ACTION_CREATED,
|
||||
'target_type' => CourierSurchargeAuditLog::TARGET_TYPE,
|
||||
'target_id' => 999,
|
||||
'target_label' => '其他公司的私有类型',
|
||||
'changes' => ['after' => ['name' => '其他公司的私有类型']],
|
||||
]);
|
||||
|
||||
$this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge']))
|
||||
->assertOk()
|
||||
->assertSeeText('附加费类型 · 日志专用类型')
|
||||
->assertDontSeeText('其他公司的私有类型');
|
||||
}
|
||||
|
||||
public function test_existing_quotes_remain_visible_but_writes_stop_when_audit_table_is_missing(): void
|
||||
{
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$quote = $this->createQuote();
|
||||
Schema::drop('business_courier_surcharge_audit_logs');
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge']))
|
||||
->assertOk()
|
||||
->assertSeeText($quote->courier)
|
||||
->assertSeeText('操作日志表尚未完成迁移')
|
||||
->assertSee('data-surcharge-open disabled', false);
|
||||
|
||||
$this->withExceptionHandling()->post(route('business.courier-surcharge.store'), [
|
||||
'courier' => '不应保存的快递',
|
||||
'scope' => '自有快递',
|
||||
'reconciler' => '周会计',
|
||||
'types' => ['进仓费'],
|
||||
'mode' => '固定单价',
|
||||
'standard' => '10 元/单',
|
||||
'condition' => '测试',
|
||||
'effective_at' => '2026-09-21',
|
||||
])->assertServiceUnavailable();
|
||||
|
||||
$this->assertDatabaseMissing('business_courier_surcharge_quotes', ['courier' => '不应保存的快递']);
|
||||
}
|
||||
|
||||
private function createQuote(): CourierSurchargeQuote
|
||||
{
|
||||
return CourierSurchargeQuote::query()->create([
|
||||
|
||||
@ -8,11 +8,105 @@ use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepo
|
||||
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
|
||||
use Modules\EcommerceFinance\View\OrderDataPageData;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class PagesTest extends FeatureTestCase
|
||||
{
|
||||
public function testInventoryReportProvidesAFilteredExportButton(): void
|
||||
{
|
||||
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('paginateMonthlyInventoryReport')->once()->with(
|
||||
[
|
||||
'month' => '2026-08',
|
||||
'product_code' => '121630-49',
|
||||
'warehouse_provider' => '',
|
||||
'category' => '',
|
||||
],
|
||||
1,
|
||||
Mockery::type('int'),
|
||||
)->andReturn([
|
||||
'data' => [(object) ['product_code' => '121630-49']],
|
||||
'total' => 1,
|
||||
'state' => ['status' => 'succeeded'],
|
||||
'sync' => ['code' => 'healthy'],
|
||||
]);
|
||||
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
|
||||
|
||||
$response = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'product-inventory',
|
||||
'page' => 'inventory-report',
|
||||
'month' => '2026-08',
|
||||
'product_code' => '121630-49',
|
||||
]));
|
||||
|
||||
$response->assertOk()
|
||||
->assertSeeText('导出')
|
||||
->assertSee(e(route('business.product-inventory.inventory-report.export', [
|
||||
'month' => '2026-08',
|
||||
'product_code' => '121630-49',
|
||||
])), false);
|
||||
}
|
||||
|
||||
public function testInventoryReportExportUsesCurrentFiltersAndPageColumnOrder(): void
|
||||
{
|
||||
$filters = [
|
||||
'month' => '2026-08',
|
||||
'product_code' => '121630-49',
|
||||
'warehouse_provider' => '测试仓',
|
||||
'category' => '家具',
|
||||
];
|
||||
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('exportMonthlyInventoryReportRows')->once()->with($filters)
|
||||
->andReturn((static function (): \Generator {
|
||||
yield (object) [
|
||||
'owner' => '常州简尚家居有限公司',
|
||||
'month' => '2026-08-01',
|
||||
'warehouse_provider' => '测试仓',
|
||||
'warehouse' => '测试仓',
|
||||
'product_code' => '121630-49',
|
||||
'category' => '家具',
|
||||
'opening_quantity' => 10,
|
||||
'closing_quantity' => 12,
|
||||
];
|
||||
})());
|
||||
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
|
||||
|
||||
$response = $this->loginAs()->get(route(
|
||||
'business.product-inventory.inventory-report.export',
|
||||
$filters,
|
||||
));
|
||||
|
||||
$response->assertOk()->assertDownload();
|
||||
$content = $response->streamedContent();
|
||||
$this->assertStringStartsWith(chr(0xEF) . chr(0xBB) . chr(0xBF), $content);
|
||||
$this->assertStringContainsString('货主,月份,仓储方,仓库,商品编码,分类,期初数量,期末数量', $content);
|
||||
$this->assertStringContainsString("'121630-49", $content);
|
||||
}
|
||||
|
||||
public function testInventoryReportDoesNotFallBackToLegacyRowsBeforeAllWarehouseMirrorIsReady(): void
|
||||
{
|
||||
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('allWarehousesReady')->once()->andReturnFalse();
|
||||
$repository->shouldNotReceive('paginateMonthlyInventoryReport');
|
||||
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', [
|
||||
'section' => 'product-inventory',
|
||||
'page' => 'inventory-report',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('进销存全仓库共享镜像正在执行首次同步')
|
||||
->assertDontSeeText('已从共享业务镜像读取');
|
||||
}
|
||||
|
||||
public function testFreightReconciliationPageRendersSharedMirrorRowsAndSummary(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
|
||||
@ -64,6 +64,30 @@ class ImportTaobaoAlipayBillsTest extends TestCase
|
||||
->assertFailed();
|
||||
}
|
||||
|
||||
public function testReplaceValidatesFirstAndAtomicallyRemovesOldRows(): void
|
||||
{
|
||||
$old = AlipayBill::query()->create([
|
||||
'company_id' => 1, 'record_code' => 'OLD-ROW', 'store_name' => '旧店铺',
|
||||
'bill_item' => '旧账单', 'business_type' => '', 'alipay_account' => '',
|
||||
'counterparty_account' => '', 'account_order_no' => '', 'business_serial_no' => '',
|
||||
'goods_name' => '', 'income' => 0, 'expense' => 0, 'balance' => 0,
|
||||
'platform' => 'taobao', 'created_from' => 'old-data',
|
||||
]);
|
||||
|
||||
$this->artisan('business:import-taobao-alipay-bills', [
|
||||
'file' => $this->workbook('错误表头'), '--company' => 1, '--replace' => true,
|
||||
])->assertFailed();
|
||||
$this->assertDatabaseHas('ec_alipay_bills', ['id' => $old->id, 'record_code' => 'OLD-ROW']);
|
||||
|
||||
$this->artisan('business:import-taobao-alipay-bills', [
|
||||
'file' => $this->workbook(), '--company' => 1, '--replace' => true,
|
||||
])->expectsOutputToContain('替换旧数据 1 条')->assertSuccessful();
|
||||
|
||||
$this->assertSame(1, AlipayBill::query()->where('company_id', 1)->count());
|
||||
$this->assertDatabaseMissing('ec_alipay_bills', ['record_code' => 'OLD-ROW']);
|
||||
$this->assertDatabaseHas('ec_alipay_bills', ['record_code' => '2000000001141269665']);
|
||||
}
|
||||
|
||||
private function workbook(?string $firstHeader = null, string $sheetName = '淘系支付宝账户'): string
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
|
||||
182
tests/Feature/Console/InventoryReportAuthoritativeImportTest.php
Normal file
182
tests/Feature/Console/InventoryReportAuthoritativeImportTest.php
Normal file
@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Services\InventoryReportAuthoritativeImporter;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InventoryReportAuthoritativeImportTest extends TestCase
|
||||
{
|
||||
private array $temporaryFiles = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config([
|
||||
'database.connections.business_mirror' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
],
|
||||
'upstream_mirrors.connection' => 'business_mirror',
|
||||
]);
|
||||
DB::purge('business_mirror');
|
||||
$this->createTables();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->temporaryFiles as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_it_imports_monthly_warehouse_rows_and_preserves_opening_quantity(): void
|
||||
{
|
||||
$path = $this->workbook([
|
||||
$this->headers(),
|
||||
$this->row('2026-08-01', '简尚-崔桥工厂仓', '简尚-崔桥工厂仓主仓', '15962G', 14, 6, [
|
||||
'销售出仓' => -28,
|
||||
'调拨入' => 20,
|
||||
]),
|
||||
$this->row('2026-08-01', '简尚-崔桥工厂仓', '简尚-崔桥工厂仓进货仓', '15962G', 0, 0, [
|
||||
'采购进仓' => 20,
|
||||
'调拨出' => -20,
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->artisan('business-mirror:import-inventory-report-month', ['path' => $path])
|
||||
->assertSuccessful();
|
||||
|
||||
$state = DB::connection('business_mirror')->table('inventory_report_authoritative_states')->first();
|
||||
$this->assertSame(InventoryReportAuthoritativeImporter::DATASET_KEY, $state->dataset_key);
|
||||
$this->assertSame('2026-08-01', $state->source_month);
|
||||
$this->assertSame(2, (int) $state->source_row_count);
|
||||
$this->assertDatabaseHas('inventory_report_authoritative_rows', [
|
||||
'generation' => $state->active_generation,
|
||||
'warehouse' => '简尚-崔桥工厂仓主仓',
|
||||
'product_code' => '15962G',
|
||||
'opening_quantity' => 14,
|
||||
'closing_quantity' => 6,
|
||||
'sales_out' => -28,
|
||||
'transfer_in' => 20,
|
||||
], 'business_mirror');
|
||||
}
|
||||
|
||||
public function test_invalid_replacement_keeps_previous_generation(): void
|
||||
{
|
||||
$valid = app(InventoryReportAuthoritativeImporter::class)->import($this->workbook([
|
||||
$this->headers(),
|
||||
$this->row('2026-08-01', '仓储方', '主仓', 'SKU-A', 1, 1),
|
||||
]));
|
||||
$invalid = $this->workbook([
|
||||
$this->headers(),
|
||||
$this->row('2026-08-01', '仓储方', '主仓', 'SKU-A', 1, 2),
|
||||
]);
|
||||
|
||||
$this->artisan('business-mirror:import-inventory-report-month', ['path' => $invalid])
|
||||
->expectsOutputToContain('已保留上一版有效数据')
|
||||
->assertFailed();
|
||||
|
||||
$state = DB::connection('business_mirror')->table('inventory_report_authoritative_states')->first();
|
||||
$this->assertSame($valid['generation'], $state->active_generation);
|
||||
$this->assertSame(1, DB::connection('business_mirror')->table('inventory_report_authoritative_rows')
|
||||
->where('generation', $valid['generation'])->count());
|
||||
}
|
||||
|
||||
private function createTables(): void
|
||||
{
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('inventory_report_authoritative_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->string('status');
|
||||
$table->string('active_generation')->nullable();
|
||||
$table->string('pending_generation')->nullable();
|
||||
$table->string('source_filename')->nullable();
|
||||
$table->string('source_sha256')->nullable();
|
||||
$table->date('source_month')->nullable();
|
||||
$table->unsignedBigInteger('source_row_count')->default(0);
|
||||
$table->dateTime('last_attempted_at')->nullable();
|
||||
$table->dateTime('last_succeeded_at')->nullable();
|
||||
$table->string('last_error_code')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
$schema->create('inventory_report_authoritative_rows', function (Blueprint $table): void {
|
||||
$table->string('generation');
|
||||
$table->string('row_key');
|
||||
$table->date('report_month');
|
||||
$table->string('owner');
|
||||
$table->string('warehouse_provider');
|
||||
$table->string('warehouse');
|
||||
$table->string('product_code');
|
||||
$table->string('category')->nullable();
|
||||
foreach ([
|
||||
'opening_quantity', 'closing_quantity', 'processing_in', 'processing_out',
|
||||
'purchase_in', 'purchase_return', 'other_in', 'other_return', 'sales_out',
|
||||
'sales_return', 'stocktake', 'transfer_out', 'transfer_in', 'opening', 'other_out',
|
||||
] as $column) {
|
||||
$table->decimal($column, 20, 4)->default(0);
|
||||
}
|
||||
$table->dateTime('imported_at');
|
||||
$table->primary(['generation', 'row_key']);
|
||||
});
|
||||
}
|
||||
|
||||
private function headers(): array
|
||||
{
|
||||
return [
|
||||
'货主', '月份', '仓储方', '仓库', '商品编码', '图片', '商品名称', '商品简称',
|
||||
'分类', '虚拟分类', '供应商款号', '颜色规格', '品牌', '单位', '期初数量', '期末数量',
|
||||
'加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货',
|
||||
'销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库',
|
||||
];
|
||||
}
|
||||
|
||||
private function row(
|
||||
string $month,
|
||||
string $provider,
|
||||
string $warehouse,
|
||||
string $sku,
|
||||
float $opening,
|
||||
float $closing,
|
||||
array $movements = [],
|
||||
): array {
|
||||
$row = array_fill(0, 29, null);
|
||||
$row[0] = '常州简尚家居有限公司';
|
||||
$row[1] = $month;
|
||||
$row[2] = $provider;
|
||||
$row[3] = $warehouse;
|
||||
$row[4] = $sku;
|
||||
$row[8] = '测试分类';
|
||||
$row[14] = $opening;
|
||||
$row[15] = $closing;
|
||||
foreach ([
|
||||
'加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货',
|
||||
'销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库',
|
||||
] as $offset => $header) {
|
||||
$row[16 + $offset] = $movements[$header] ?? 0;
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function workbook(array $rows): string
|
||||
{
|
||||
$temporary = tempnam(sys_get_temp_dir(), 'inventory-report-month-');
|
||||
$path = $temporary . '.xlsx';
|
||||
rename($temporary, $path);
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$spreadsheet->getActiveSheet()->fromArray($rows);
|
||||
(new Xlsx($spreadsheet))->save($path);
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
$this->temporaryFiles[] = $path;
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@ -18,7 +18,58 @@ class NavigationTest extends FeatureTestCase
|
||||
->assertSeeText('商品价格管理')
|
||||
->assertSee('/workspace/order-data/raw-data', false)
|
||||
->assertSee('/workspace/refunds/non-jingxi-refund-data', false)
|
||||
->assertSee('/workspace/product-inventory/normal-products', false);
|
||||
->assertSee('/workspace/product-inventory/normal-products', false)
|
||||
->assertSee('/workspace/product-inventory/inventory-report', false);
|
||||
}
|
||||
|
||||
public function testInventoryReportMenuPageIsAvailable(): void
|
||||
{
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', [
|
||||
'section' => 'product-inventory',
|
||||
'page' => 'inventory-report',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('进销存报表')
|
||||
->assertSeeTextInOrder([
|
||||
'货主', '月份', '仓储方', '仓库', '商品编码', '分类',
|
||||
'期初数量', '期末数量', '加工进仓', '加工出仓', '采购进仓',
|
||||
'采购退货', '其它进仓', '其它退货', '销售出仓', '销售退货',
|
||||
'盘点', '调拨出', '调拨入', '期初', '其它出库',
|
||||
])
|
||||
->assertSeeText('复用“应付款项 → 快递应付 → 三方仓进销存”')
|
||||
->assertSeeText('进销存共享镜像尚未建立')
|
||||
->assertDontSeeText('已按附件模板导入 20 条演示数据')
|
||||
->assertDontSeeText('进销存报表字段和数据来源尚未定义。');
|
||||
}
|
||||
|
||||
public function testFinancialRiskControlPageIsAvailableBetweenFinancialReports(): void
|
||||
{
|
||||
$navigation = BusinessNavigation::all();
|
||||
$slugs = array_column($navigation, 'slug');
|
||||
$riskControlIndex = array_search('financial-risk-control', $slugs, true);
|
||||
|
||||
$this->assertNotFalse($riskControlIndex);
|
||||
$this->assertSame('financial-operations', $slugs[$riskControlIndex - 1]);
|
||||
$this->assertSame('financial-sw', $slugs[$riskControlIndex + 1]);
|
||||
$this->assertSame('财务经营风险管控', $navigation[$riskControlIndex]['title']);
|
||||
$this->assertSame('security', $navigation[$riskControlIndex]['icon']);
|
||||
$this->assertSame([], $navigation[$riskControlIndex]['pages']);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', ['section' => 'financial-risk-control']) . '?platform=jd')
|
||||
->assertOk()
|
||||
->assertSeeText('财务经营风险管控')
|
||||
->assertSeeText('淘系')
|
||||
->assertSeeText('阿里巴巴')
|
||||
->assertSeeText('京东')
|
||||
->assertSeeText('拼多多')
|
||||
->assertSeeText('抖音')
|
||||
->assertSeeText('京东店铺违规信息预警')
|
||||
->assertSee('data-active-platform="jd"', false)
|
||||
->assertSee('id="financial-risk-control-panel-jd"', false)
|
||||
->assertSee('data-platform-tab="jd"', false)
|
||||
->assertSee('/workspace/financial-risk-control', false);
|
||||
}
|
||||
|
||||
public function testGroupedNavigationKeepsRestrictedPagePermissions(): void
|
||||
@ -91,6 +142,7 @@ class NavigationTest extends FeatureTestCase
|
||||
$receivablesIndex = array_search('store-receivables', $slugs, true);
|
||||
$orderData = $navigation[$orderIndex] ?? null;
|
||||
$refund = $navigation[$refundIndex] ?? null;
|
||||
$productInventory = $navigation[$productIndex] ?? null;
|
||||
$financialOperations = collect($navigation)->firstWhere('slug', 'financial-operations');
|
||||
|
||||
$this->assertNotFalse($invoiceIndex);
|
||||
@ -148,6 +200,14 @@ class NavigationTest extends FeatureTestCase
|
||||
['订单售后费用明细', '商品推广费用明细'],
|
||||
array_column($orderData['pages'][0]['tabs'], 'title')
|
||||
);
|
||||
$this->assertSame(
|
||||
['normal-products', 'online-daifa-price', 'inventory-report'],
|
||||
array_column($productInventory['pages'], 'slug')
|
||||
);
|
||||
$this->assertSame(
|
||||
['普通商品资料', '线上代发价格', '进销存报表'],
|
||||
array_column($productInventory['pages'], 'title')
|
||||
);
|
||||
$this->assertArrayNotHasKey('children', $orderData['pages'][0]);
|
||||
$this->assertSame(
|
||||
['bill-offset'],
|
||||
|
||||
@ -128,6 +128,21 @@ class UserPermissionsTest extends FeatureTestCase
|
||||
$this->assertSame(['subject-trademark-rules'], $pages->pluck('slug')->all());
|
||||
}
|
||||
|
||||
public function test_financial_risk_control_section_permission_keeps_platforms_inside_page(): void
|
||||
{
|
||||
$regular = $this->account('user');
|
||||
$subject = 'user_'.$regular->id;
|
||||
setting()->set('system.sidebar_permissions', [
|
||||
'configured' => [$subject],
|
||||
'subjects' => [$subject => ['financial-risk-control']],
|
||||
]);
|
||||
|
||||
$navigation = SidebarPermissions::navigationFor($regular);
|
||||
|
||||
$this->assertSame(['financial-risk-control'], array_column($navigation, 'slug'));
|
||||
$this->assertSame([], $navigation[0]['pages']);
|
||||
}
|
||||
|
||||
public function test_legacy_small_payment_permission_follows_the_page_to_financial_operations(): void
|
||||
{
|
||||
$regular = $this->account('user');
|
||||
|
||||
@ -90,10 +90,11 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
|
||||
);
|
||||
|
||||
$this->assertSame(2, $result['total']);
|
||||
$this->assertSame('2', $result['data'][0]['record_code']);
|
||||
$this->assertSame('P-2', $result['data'][0]['record_code']);
|
||||
$this->assertSame('天猫-经营店', $result['data'][0]['store_name']);
|
||||
$this->assertSame('支出', $result['data'][0]['business_type']);
|
||||
$this->assertSame('alipayCSV', $result['data'][0]['alipay_account']);
|
||||
$this->assertNull($result['data'][0]['business_type']);
|
||||
$this->assertNull($result['data'][0]['alipay_account']);
|
||||
$this->assertNull($result['data'][0]['balance']);
|
||||
$this->assertSame(100.25, $result['summary']['income']);
|
||||
$this->assertSame(20.10, $result['summary']['expense']);
|
||||
$this->assertSame(80.15, $result['summary']['balance']);
|
||||
@ -107,6 +108,40 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
|
||||
$this->repository->paginate([], 'occurred_at', 'desc', 1, 10);
|
||||
}
|
||||
|
||||
public function test_imported_attachment_rows_enrich_matching_mirror_rows_for_the_taobao_page(): void
|
||||
{
|
||||
$this->markReady();
|
||||
DB::connection('business_mirror')->table('ops_jst_shop_info')->insert([
|
||||
'Id' => 10, 'shop_id' => 7, 'shop_name' => '天猫-经营店', 'shop_site' => '淘宝天猫',
|
||||
]);
|
||||
DB::connection('business_mirror')->table('ops_jst_original_bill_info')->insert(
|
||||
$this->row(1, 7, '2026-08-01 09:00:00', '1', 100.25),
|
||||
);
|
||||
DB::table('ec_alipay_bills')->insert([
|
||||
'company_id' => 1, 'record_code' => 'P-1', 'store_name' => '天猫-附件店铺',
|
||||
'custom_remark' => '模板数据', 'bill_item' => '交易收款', 'alipay_account' => '20880001',
|
||||
'account_order_no' => 'ACCOUNT-1', 'business_serial_no' => 'SERIAL-1',
|
||||
'merchant_order_no' => 'MERCHANT-1', 'business_base_order_no' => 'BASE-1',
|
||||
'goods_name' => '附件商品', 'counterparty_account' => '买家',
|
||||
'occurred_at' => '2026-08-01 09:00:00', 'income' => 100.25, 'expense' => 0, 'balance' => 300.50,
|
||||
'business_type' => '交易付款', 'remark' => '备注', 'online_order_no' => 'ONLINE-1',
|
||||
'online_real_store' => '天猫-附件店铺', 'business_description' => '业务描述',
|
||||
'business_bill_source' => '支付宝', 'business_order_no' => 'BUSINESS-1',
|
||||
'modifier' => '导入人', 'platform' => 'taobao', 'created_from' => 'attachment-import',
|
||||
'created_at' => '2026-08-02 09:00:00', 'updated_at' => '2026-08-02 10:00:00',
|
||||
]);
|
||||
|
||||
$result = $this->repository->paginate([], 'occurred_at', 'desc', 1, 10);
|
||||
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame('P-1', $result['data'][0]['record_code']);
|
||||
$this->assertSame('天猫-经营店', $result['data'][0]['store_name']);
|
||||
$this->assertSame('20880001', $result['data'][0]['alipay_account']);
|
||||
$this->assertSame(300.50, $result['data'][0]['balance']);
|
||||
$this->assertSame(100.25, $result['summary']['income']);
|
||||
$this->assertSame('succeeded', $result['sync']['status']);
|
||||
}
|
||||
|
||||
public function test_export_uses_the_same_filters_and_mapping_as_the_page(): void
|
||||
{
|
||||
$this->markReady();
|
||||
@ -119,11 +154,11 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
|
||||
]);
|
||||
|
||||
$rows = iterator_to_array($this->repository->exportRows([
|
||||
'business_type' => '支出', 'alipay_account' => 'alipayCSV', 'keyword' => 'SKU-2',
|
||||
'business_type' => '支出', 'source_channel' => 'alipayCSV', 'keyword' => 'SKU-2',
|
||||
]));
|
||||
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertSame('2', $rows[0]['record_code']);
|
||||
$this->assertSame('P-2', $rows[0]['record_code']);
|
||||
$this->assertSame(20.10, $rows[0]['expense']);
|
||||
}
|
||||
|
||||
@ -197,7 +232,7 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
|
||||
}
|
||||
foreach (array_chunk($fixtures, 100) as $chunk) $table->insert($chunk);
|
||||
$table->insert($this->row(9999, 7, '2026-08-01 09:00:00', '1', 10, 'OtherChannel'));
|
||||
foreach (['occurred_at' => 'fee_time', 'income' => 'amount', 'record_code' => 'Id'] as $sort => $column) {
|
||||
foreach (['occurred_at' => 'fee_time', 'income' => 'amount', 'record_code' => 'p_auto_id'] as $sort => $column) {
|
||||
foreach (['asc', 'desc'] as $direction) {
|
||||
$expected = (clone $table)->where('channel', 'CommonBillTGCPay')
|
||||
->orderBy($column, $direction)->orderBy('Id', $direction)->pluck('Id')->map('strval')->all();
|
||||
|
||||
@ -42,6 +42,7 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
|
||||
$this->createMirrorTables();
|
||||
$this->createProductTable();
|
||||
$this->createWarehouseTable();
|
||||
$this->createDependencyStateTable();
|
||||
$this->repository = new ThirdPartyWarehouseStorageMirrorRepository(app('db'));
|
||||
}
|
||||
@ -139,6 +140,228 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
$this->assertSame(15.0, $early['closing_qty']);
|
||||
}
|
||||
|
||||
public function test_inventory_report_aggregates_the_same_confirmed_movements_by_product_warehouse_and_month(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-MONTHLY', ['category' => '住宅家具 - 书桌']);
|
||||
$this->insertInventory('SKU-MONTHLY', 12, '2026-09-01 00:00:00');
|
||||
$this->insertMovement('SKU-MONTHLY', '采购进仓', 5, 1, '2026-08-02 10:00:00');
|
||||
$this->insertMovement('SKU-MONTHLY', '销售出仓', 3, -1, '2026-08-15 10:00:00');
|
||||
$this->insertMovement('SKU-MONTHLY', '销售退货', 2, 1, '2026-08-20 10:00:00');
|
||||
|
||||
$result = $this->repository->paginateMonthlyInventoryReport([
|
||||
'month' => '2026-08',
|
||||
'product_code' => 'SKU-MONTHLY',
|
||||
'warehouse_provider' => '南昌樵舍',
|
||||
'category' => '书桌',
|
||||
], 1, 25);
|
||||
$row = $result['data'][0];
|
||||
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame('2026-08-01', $row->month);
|
||||
$this->assertSame('三方-南昌樵舍仓', $row->warehouse_provider);
|
||||
$this->assertSame('三方-南昌樵舍仓主仓', $row->warehouse);
|
||||
$this->assertSame('SKU-MONTHLY', $row->product_code);
|
||||
$this->assertSame('住宅家具 - 书桌', $row->category);
|
||||
$this->assertSame(8.0, (float) $row->opening_quantity);
|
||||
$this->assertSame(12.0, (float) $row->closing_quantity);
|
||||
$this->assertSame(5.0, (float) $row->purchase_in);
|
||||
$this->assertSame(-3.0, (float) $row->sales_out);
|
||||
$this->assertSame(2.0, (float) $row->sales_return);
|
||||
}
|
||||
|
||||
public function test_inventory_report_prefers_authoritative_month_rows_with_exact_opening_quantities(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-AUTH');
|
||||
$this->insertInventory('SKU-AUTH', 999, '2026-09-01 00:00:00');
|
||||
$this->insertMovement('SKU-AUTH', '采购进仓', 500, 1, '2026-08-20 10:00:00');
|
||||
$this->insertInventoryReportAuthoritativeRow([
|
||||
'warehouse_provider' => '简尚-崔桥工厂仓',
|
||||
'warehouse' => '简尚-崔桥工厂仓进货仓',
|
||||
'product_code' => 'SKU-AUTH',
|
||||
'category' => '权威分类',
|
||||
'opening_quantity' => 14,
|
||||
'closing_quantity' => 20,
|
||||
'purchase_in' => 6,
|
||||
]);
|
||||
|
||||
$result = $this->repository->paginateMonthlyInventoryReport([
|
||||
'month' => '2026-08',
|
||||
'product_code' => 'SKU-AUTH',
|
||||
'warehouse_provider' => '崔桥',
|
||||
'category' => '权威',
|
||||
], 1, 25);
|
||||
$row = $result['data'][0];
|
||||
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame('简尚-崔桥工厂仓进货仓', $row->warehouse);
|
||||
$this->assertSame(14.0, (float) $row->opening_quantity);
|
||||
$this->assertSame(20.0, (float) $row->closing_quantity);
|
||||
$this->assertSame(6.0, (float) $row->purchase_in);
|
||||
}
|
||||
|
||||
public function test_inventory_report_product_code_filter_is_exact(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('15962G');
|
||||
$this->insertProduct('15962G-1');
|
||||
$this->insertInventory('15962G', 1, '2026-09-01 00:00:00');
|
||||
$this->insertInventory('15962G-1', 1, '2026-09-01 00:00:00');
|
||||
|
||||
$result = $this->repository->paginateMonthlyInventoryReport([
|
||||
'month' => '2026-08',
|
||||
'product_code' => '15962G',
|
||||
'warehouse_provider' => '',
|
||||
'category' => '',
|
||||
], 1, 25);
|
||||
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame(['15962G'], collect($result['data'])->pluck('product_code')->all());
|
||||
}
|
||||
|
||||
public function test_inventory_report_splits_main_return_inbound_and_other_subwarehouses(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-SUBWAREHOUSE', ['category' => '测试分类']);
|
||||
$this->insertInventory('SKU-SUBWAREHOUSE', 10, '2026-09-01 00:00:00', [
|
||||
'return_qty' => 4,
|
||||
'in_qty' => 3,
|
||||
'customize_qty_1' => 2,
|
||||
]);
|
||||
$this->insertMovement('SKU-SUBWAREHOUSE', '销售退货', 2, 1, '2026-08-20 10:00:00', ['wh_id' => 2]);
|
||||
$this->insertMovement('SKU-SUBWAREHOUSE', '采购进仓', 3, 1, '2026-08-21 10:00:00', ['wh_id' => 3]);
|
||||
|
||||
$result = $this->repository->paginateMonthlyInventoryReport([
|
||||
'month' => '2026-08',
|
||||
'product_code' => 'SKU-SUBWAREHOUSE',
|
||||
'warehouse_provider' => '',
|
||||
'category' => '',
|
||||
], 1, 25);
|
||||
$rows = collect($result['data'])->keyBy('warehouse');
|
||||
|
||||
$this->assertSame(4, $result['total']);
|
||||
$this->assertSame([
|
||||
'三方-南昌樵舍仓主仓',
|
||||
'三方-南昌樵舍仓销退仓',
|
||||
'三方-南昌樵舍仓进货仓',
|
||||
'三方-南昌樵舍仓自定义仓1',
|
||||
], $rows->keys()->all());
|
||||
$this->assertSame(2.0, (float) $rows['三方-南昌樵舍仓销退仓']->opening_quantity);
|
||||
$this->assertSame(4.0, (float) $rows['三方-南昌樵舍仓销退仓']->closing_quantity);
|
||||
$this->assertSame(2.0, (float) $rows['三方-南昌樵舍仓销退仓']->sales_return);
|
||||
$this->assertSame(0.0, (float) $rows['三方-南昌樵舍仓进货仓']->opening_quantity);
|
||||
$this->assertSame(3.0, (float) $rows['三方-南昌樵舍仓进货仓']->closing_quantity);
|
||||
}
|
||||
|
||||
public function test_inventory_report_uses_export_warehouse_names_for_special_subwarehouses(): void
|
||||
{
|
||||
$this->markReady();
|
||||
DB::connection('business_mirror')->table('ops_jst_warehouse_info')->insert([
|
||||
'Id' => 3,
|
||||
'wms_co_id' => 15033302,
|
||||
'name' => '简尚-横林二仓',
|
||||
]);
|
||||
$this->insertProduct('SKU-SPECIAL-WAREHOUSE');
|
||||
$this->insertInventory('SKU-SPECIAL-WAREHOUSE', 8, '2026-09-01 00:00:00', [
|
||||
'wms_co_id' => 15033302,
|
||||
'customize_qty_1' => 5,
|
||||
'customize_qty_2' => 2,
|
||||
]);
|
||||
|
||||
$result = $this->repository->paginateMonthlyInventoryReport([
|
||||
'month' => '2026-08',
|
||||
'product_code' => 'SKU-SPECIAL-WAREHOUSE',
|
||||
'warehouse_provider' => '',
|
||||
'category' => '',
|
||||
], 1, 25);
|
||||
|
||||
$this->assertSame([
|
||||
'简尚-横林二仓主仓',
|
||||
'客退临时上架仓',
|
||||
'数量校验仓',
|
||||
], collect($result['data'])->pluck('warehouse')->all());
|
||||
}
|
||||
|
||||
public function test_inventory_report_uses_accessory_warehouse_name_for_company_inventory(): void
|
||||
{
|
||||
$this->markReady();
|
||||
DB::connection('business_mirror')->table('ops_jst_warehouse_info')->insert([
|
||||
'Id' => 3,
|
||||
'wms_co_id' => 14138566,
|
||||
'name' => '常州简尚家居有限公司',
|
||||
]);
|
||||
$this->insertProduct('121630-49');
|
||||
$this->insertInventory('121630-49', -2, '2026-09-01 00:00:00', [
|
||||
'wms_co_id' => 14138566,
|
||||
]);
|
||||
|
||||
$result = $this->repository->paginateMonthlyInventoryReport([
|
||||
'month' => '2026-08',
|
||||
'product_code' => '121630-49',
|
||||
'warehouse_provider' => '',
|
||||
'category' => '',
|
||||
], 1, 25);
|
||||
|
||||
$this->assertSame('配件单仓', $result['data'][0]->warehouse);
|
||||
}
|
||||
|
||||
public function test_inventory_report_reads_and_filters_warehouses_outside_the_third_party_scope(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-ALL-WAREHOUSES', ['category' => '住宅家具 - 餐边柜']);
|
||||
$this->insertInventory('SKU-ALL-WAREHOUSES', 9, '2026-09-01 00:00:00', [
|
||||
'wms_co_id' => 15038426,
|
||||
]);
|
||||
$this->insertMovement('SKU-ALL-WAREHOUSES', '销售出仓', 20, -1, '2026-08-15 10:00:00', [
|
||||
'warehouse_id' => 15038426,
|
||||
]);
|
||||
|
||||
$result = $this->repository->paginateMonthlyInventoryReport([
|
||||
'month' => '2026-08',
|
||||
'product_code' => 'SKU-ALL-WAREHOUSES',
|
||||
'warehouse_provider' => '崔桥',
|
||||
'category' => '餐边柜',
|
||||
], 1, 25);
|
||||
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame('简尚-崔桥工厂仓', $result['data'][0]->warehouse_provider);
|
||||
$this->assertSame('SKU-ALL-WAREHOUSES', $result['data'][0]->product_code);
|
||||
$this->assertSame(-20.0, (float) $result['data'][0]->sales_out);
|
||||
}
|
||||
|
||||
public function test_inventory_report_uses_only_product_codes_matched_to_the_operations_replica_catalog(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-STOCK');
|
||||
$this->insertInventory('SKU-STOCK', 2, '2026-09-01 00:00:00');
|
||||
$this->insertInventory('LEGACY-CODE-NOT-IN-CATALOG', 7, '2026-09-01 00:00:00');
|
||||
|
||||
$result = $this->repository->paginateMonthlyInventoryReport([
|
||||
'month' => '2026-08',
|
||||
'product_code' => '',
|
||||
'warehouse_provider' => '',
|
||||
'category' => '',
|
||||
], 1, 25);
|
||||
$codes = collect($result['data'])->pluck('product_code')->all();
|
||||
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame(['SKU-STOCK'], $codes);
|
||||
$this->assertNotContains('LEGACY-CODE-NOT-IN-CATALOG', $codes);
|
||||
}
|
||||
|
||||
public function test_third_party_page_remains_ready_when_only_the_all_warehouse_dimension_is_missing(): void
|
||||
{
|
||||
$this->markReady();
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')
|
||||
->where('dataset_key', 'operations_replica.rpt.jst_warehouseInfo')
|
||||
->delete();
|
||||
Schema::connection('business_mirror')->drop('ops_jst_warehouse_info');
|
||||
|
||||
$this->assertTrue($this->repository->ready());
|
||||
$this->assertFalse($this->repository->allWarehousesReady());
|
||||
}
|
||||
|
||||
public function test_periods_before_the_supported_start_date_are_clamped(): void
|
||||
{
|
||||
$this->markReady();
|
||||
@ -411,6 +634,7 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->unsignedTinyInteger('scope_version')->default(1);
|
||||
$table->string('status')->nullable();
|
||||
$table->string('run_mode')->nullable();
|
||||
$table->string('active_header_generation')->nullable();
|
||||
@ -426,6 +650,7 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('io_id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->unsignedBigInteger('wh_id')->default(0);
|
||||
$table->string('status')->nullable();
|
||||
$table->string('io_type')->nullable();
|
||||
$table->dateTime('occurred_at')->nullable();
|
||||
@ -451,6 +676,16 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
$table->dateTime('source_modified_at')->nullable();
|
||||
$table->dateTime('captured_at')->nullable();
|
||||
});
|
||||
$schema->create('third_party_warehouse_inventory_subwarehouse_snapshots', function (Blueprint $table): void {
|
||||
$table->id('_mirror_id');
|
||||
$table->string('generation');
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->unsignedSmallInteger('wh_id');
|
||||
$table->string('sku_code');
|
||||
$table->decimal('qty', 20, 4);
|
||||
$table->dateTime('captured_at')->nullable();
|
||||
});
|
||||
$schema->create('third_party_warehouse_authoritative_anchor_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->string('status');
|
||||
@ -480,6 +715,30 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
}
|
||||
$table->primary(['generation', 'report_date', 'sku_code']);
|
||||
});
|
||||
$schema->create('inventory_report_authoritative_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->string('status');
|
||||
$table->string('active_generation')->nullable();
|
||||
$table->date('source_month')->nullable();
|
||||
});
|
||||
$schema->create('inventory_report_authoritative_rows', function (Blueprint $table): void {
|
||||
$table->string('generation');
|
||||
$table->string('row_key');
|
||||
$table->date('report_month');
|
||||
$table->string('owner');
|
||||
$table->string('warehouse_provider');
|
||||
$table->string('warehouse');
|
||||
$table->string('product_code');
|
||||
$table->string('category')->nullable();
|
||||
foreach ([
|
||||
'opening_quantity', 'closing_quantity', 'processing_in', 'processing_out',
|
||||
'purchase_in', 'purchase_return', 'other_in', 'other_return', 'sales_out',
|
||||
'sales_return', 'stocktake', 'transfer_out', 'transfer_in', 'opening', 'other_out',
|
||||
] as $column) {
|
||||
$table->decimal($column, 20, 4)->default(0);
|
||||
}
|
||||
$table->primary(['generation', 'row_key']);
|
||||
});
|
||||
}
|
||||
|
||||
private function createProductTable(): void
|
||||
@ -518,6 +777,25 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
'initial_completed_at' => '2026-09-01 03:00:00',
|
||||
'last_succeeded_at' => '2026-09-11 03:00:00',
|
||||
],
|
||||
[
|
||||
'dataset_key' => 'operations_replica.rpt.jst_warehouseInfo',
|
||||
'status' => 'succeeded',
|
||||
'initial_completed_at' => '2026-09-01 03:00:00',
|
||||
'last_succeeded_at' => '2026-09-11 03:00:00',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function createWarehouseTable(): void
|
||||
{
|
||||
Schema::connection('business_mirror')->create('ops_jst_warehouse_info', function (Blueprint $table): void {
|
||||
$table->unsignedBigInteger('Id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->string('name')->nullable();
|
||||
});
|
||||
DB::connection('business_mirror')->table('ops_jst_warehouse_info')->insert([
|
||||
['Id' => 1, 'wms_co_id' => ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID, 'name' => '三方-南昌樵舍仓'],
|
||||
['Id' => 2, 'wms_co_id' => 15038426, 'name' => '简尚-崔桥工厂仓'],
|
||||
]);
|
||||
}
|
||||
|
||||
@ -525,6 +803,7 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
private function markReady(array $overrides = []): void
|
||||
{
|
||||
$this->insertState(array_merge([
|
||||
'scope_version' => 2,
|
||||
'status' => 'succeeded',
|
||||
'run_mode' => null,
|
||||
'active_header_generation' => self::HEADER_GENERATION,
|
||||
@ -586,14 +865,41 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
private function insertInventory(string $sku, float $qty, string $capturedAt, array $overrides = []): void
|
||||
{
|
||||
static $id = 1;
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_inventory_snapshots')->insert(array_merge([
|
||||
$subwarehouseValues = array_merge([
|
||||
'qty' => $qty,
|
||||
'return_qty' => 0,
|
||||
'in_qty' => 0,
|
||||
'defective_qty' => 0,
|
||||
'customize_qty_1' => 0,
|
||||
'customize_qty_2' => 0,
|
||||
'customize_qty_3' => 0,
|
||||
], array_intersect_key($overrides, array_flip([
|
||||
'qty', 'return_qty', 'in_qty', 'defective_qty',
|
||||
'customize_qty_1', 'customize_qty_2', 'customize_qty_3',
|
||||
])));
|
||||
$values = array_merge([
|
||||
'generation' => self::INVENTORY_GENERATION,
|
||||
'id' => $id++,
|
||||
'wms_co_id' => ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID,
|
||||
'sku_code' => $sku,
|
||||
'qty' => $qty,
|
||||
'captured_at' => $capturedAt,
|
||||
], $overrides));
|
||||
], array_diff_key($overrides, $subwarehouseValues));
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_inventory_snapshots')->insert($values);
|
||||
foreach ([
|
||||
1 => 'qty', 2 => 'return_qty', 3 => 'in_qty', 4 => 'defective_qty',
|
||||
6 => 'customize_qty_1', 7 => 'customize_qty_2', 8 => 'customize_qty_3',
|
||||
] as $warehouseId => $column) {
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_inventory_subwarehouse_snapshots')->insert([
|
||||
'generation' => $values['generation'],
|
||||
'id' => $values['id'],
|
||||
'wms_co_id' => $values['wms_co_id'],
|
||||
'wh_id' => $warehouseId,
|
||||
'sku_code' => $values['sku_code'],
|
||||
'qty' => $subwarehouseValues[$column],
|
||||
'captured_at' => $values['captured_at'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function insertAuthoritativeAnchor(string $sku, float $qty, string $anchorAt): void
|
||||
@ -615,6 +921,30 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $overrides */
|
||||
private function insertInventoryReportAuthoritativeRow(array $overrides = []): void
|
||||
{
|
||||
$generation = '55555555-5555-5555-5555-555555555555';
|
||||
DB::connection('business_mirror')->table('inventory_report_authoritative_states')->insert([
|
||||
'dataset_key' => 'jiansun.inventory-report.authoritative-month',
|
||||
'status' => 'succeeded',
|
||||
'active_generation' => $generation,
|
||||
'source_month' => '2026-08-01',
|
||||
]);
|
||||
DB::connection('business_mirror')->table('inventory_report_authoritative_rows')->insert(array_merge([
|
||||
'generation' => $generation,
|
||||
'row_key' => hash('sha256', 'authoritative-row'),
|
||||
'report_month' => '2026-08-01',
|
||||
'owner' => '常州简尚家居有限公司',
|
||||
'warehouse_provider' => '仓储方',
|
||||
'warehouse' => '子仓',
|
||||
'product_code' => 'SKU-AUTH',
|
||||
'category' => '分类',
|
||||
'opening_quantity' => 0,
|
||||
'closing_quantity' => 0,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/** @param array<string,float|int> $movements */
|
||||
private function insertAuthoritativeRow(
|
||||
string $sku,
|
||||
@ -649,6 +979,7 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
'id' => $overrides['header_id'] ?? $this->nextHeaderId++,
|
||||
'io_id' => $ioId,
|
||||
'wms_co_id' => $overrides['warehouse_id'] ?? ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID,
|
||||
'wh_id' => $overrides['wh_id'] ?? 1,
|
||||
'status' => $overrides['status'] ?? 'Confirmed',
|
||||
'io_type' => $type,
|
||||
'occurred_at' => $occurredAt,
|
||||
|
||||
@ -144,6 +144,9 @@ class ThirdPartyWarehouseStorageSynchronizerTest extends TestCase
|
||||
'wms_co_id' => 15045364,
|
||||
'sku_code' => 'NEW-1',
|
||||
'qty' => 12,
|
||||
'return_qty' => 3,
|
||||
'in_qty' => 2,
|
||||
'customize_qty_1' => 1,
|
||||
'source_modified_at' => '2026-09-11T03:19:00.000',
|
||||
], [
|
||||
'source_id' => 190,
|
||||
@ -166,6 +169,18 @@ class ThirdPartyWarehouseStorageSynchronizerTest extends TestCase
|
||||
->table('third_party_warehouse_inventory_snapshots')
|
||||
->where('generation', $state->active_inventory_generation)
|
||||
->orderBy('id')->pluck('sku_code')->all());
|
||||
$this->assertSame([
|
||||
['wh_id' => 1, 'qty' => 12.0],
|
||||
['wh_id' => 2, 'qty' => 3.0],
|
||||
['wh_id' => 3, 'qty' => 2.0],
|
||||
['wh_id' => 6, 'qty' => 1.0],
|
||||
['wh_id' => 1, 'qty' => 4.0],
|
||||
], DB::connection('business_mirror')
|
||||
->table('third_party_warehouse_inventory_subwarehouse_snapshots')
|
||||
->where('generation', $state->active_inventory_generation)
|
||||
->orderBy('id')->orderBy('wh_id')->get(['wh_id', 'qty'])
|
||||
->map(fn (object $row): array => ['wh_id' => (int) $row->wh_id, 'qty' => (float) $row->qty])
|
||||
->all());
|
||||
$this->assertSame([[0, 200, 5]], $this->upstream->inventoryCalls);
|
||||
}
|
||||
|
||||
@ -479,6 +494,7 @@ class ThirdPartyWarehouseStorageSynchronizerTest extends TestCase
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->unsignedTinyInteger('scope_version')->default(1);
|
||||
$table->string('status')->default('pending');
|
||||
$table->string('run_mode')->nullable();
|
||||
$table->string('sync_phase')->nullable();
|
||||
@ -526,6 +542,7 @@ class ThirdPartyWarehouseStorageSynchronizerTest extends TestCase
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('io_id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->unsignedBigInteger('wh_id')->default(0);
|
||||
$table->string('status')->nullable();
|
||||
$table->string('io_type')->nullable();
|
||||
$table->dateTime('occurred_at')->nullable();
|
||||
@ -563,6 +580,21 @@ class ThirdPartyWarehouseStorageSynchronizerTest extends TestCase
|
||||
$table->unique(['generation', 'id']);
|
||||
$table->unique(['generation', 'wms_co_id', 'sku_code']);
|
||||
});
|
||||
$schema->create('third_party_warehouse_inventory_subwarehouse_snapshots', function (Blueprint $table): void {
|
||||
$table->id('_mirror_id');
|
||||
$table->string('generation');
|
||||
$table->string('source_key')->default('jiansun');
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->unsignedSmallInteger('wh_id');
|
||||
$table->string('sku_code', 191);
|
||||
$table->decimal('qty', 20, 4)->default(0);
|
||||
$table->dateTime('source_modified_at')->nullable();
|
||||
$table->dateTime('captured_at')->nullable();
|
||||
$table->string('row_hash');
|
||||
$table->dateTime('synced_at');
|
||||
$table->unique(['generation', 'id', 'wh_id']);
|
||||
});
|
||||
}
|
||||
|
||||
private function seedReadyMirror(): void
|
||||
|
||||
@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase
|
||||
{
|
||||
public function test_header_queries_use_the_fixed_source_table_warehouse_and_id_window(): void
|
||||
public function test_header_queries_cover_all_warehouses_with_a_bounded_id_window(): void
|
||||
{
|
||||
$query = new RecordingThirdPartyWarehouseQuery();
|
||||
$query->scalarResult = 987654;
|
||||
@ -20,11 +20,12 @@ class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase
|
||||
|
||||
$this->assertStringContainsString('MAX([Id])', $query->scalarCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[dbo].[jst_inoutWater]', $query->scalarCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[wms_co_id] = ?', $query->scalarCalls[0]['sql']);
|
||||
$this->assertSame([15045364], $query->scalarCalls[0]['bindings']);
|
||||
$this->assertStringNotContainsString('[wms_co_id] = ?', $query->scalarCalls[0]['sql']);
|
||||
$this->assertSame([], $query->scalarCalls[0]['bindings']);
|
||||
$this->assertStringContainsString('[Id] > ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[Id] <= ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringNotContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('h.[wh_id]', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[io_date]', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('AS occurred_at', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('NOT EXISTS', $query->selectCalls[0]['sql']);
|
||||
@ -32,7 +33,7 @@ class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase
|
||||
$this->assertStringContainsString('newer.[Id] > h.[Id]', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('ORDER BY h.[Id]', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('h.[io_date] >= ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertSame([100, 200, 15045364, '2026-08-01'], $query->selectCalls[0]['bindings']);
|
||||
$this->assertSame([100, 200, '2026-08-01'], $query->selectCalls[0]['bindings']);
|
||||
}
|
||||
|
||||
public function test_items_are_parameterized_and_split_into_bounded_io_id_batches(): void
|
||||
@ -114,7 +115,7 @@ class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase
|
||||
$this->assertCount(2, $query->selectCalls);
|
||||
}
|
||||
|
||||
public function test_inventory_uses_target_warehouse_keyset_pagination_and_a_bounded_limit(): void
|
||||
public function test_inventory_uses_all_warehouse_keyset_pagination_and_a_bounded_limit(): void
|
||||
{
|
||||
$query = new RecordingThirdPartyWarehouseQuery();
|
||||
$query->scalarResult = 191818;
|
||||
@ -124,16 +125,19 @@ class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase
|
||||
$repository->inventoryBatch(12350, 191818, 99999);
|
||||
|
||||
$this->assertStringContainsString('[dbo].[jst_inventoryInfo]', $query->scalarCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[wms_co_id] = ?', $query->scalarCalls[0]['sql']);
|
||||
$this->assertSame([15045364], $query->scalarCalls[0]['bindings']);
|
||||
$this->assertStringNotContainsString('[wms_co_id] = ?', $query->scalarCalls[0]['sql']);
|
||||
$this->assertSame([], $query->scalarCalls[0]['bindings']);
|
||||
$this->assertStringContainsString('SELECT TOP (5000)', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[dbo].[jst_inventoryInfo]', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[Id] > ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[Id] <= ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringNotContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('ORDER BY [Id]', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('AS source_modified_at', $query->selectCalls[0]['sql']);
|
||||
$this->assertSame([12350, 191818, 15045364], $query->selectCalls[0]['bindings']);
|
||||
foreach (['return_qty', 'in_qty', 'defective_qty', 'customize_qty_1', 'customize_qty_2', 'customize_qty_3'] as $column) {
|
||||
$this->assertStringContainsString("[{$column}]", $query->selectCalls[0]['sql']);
|
||||
}
|
||||
$this->assertSame([12350, 191818], $query->selectCalls[0]['bindings']);
|
||||
|
||||
$repository->inventoryBatch(191818, 191818, 2000);
|
||||
$this->assertCount(1, $query->selectCalls);
|
||||
|
||||
@ -14,13 +14,14 @@ class TaobaoAlipayBillsTableTest extends TestCase
|
||||
);
|
||||
|
||||
$this->assertIsString($source);
|
||||
$this->assertStringContainsString('<th colspan="4">基础信息</th>', $source);
|
||||
$this->assertStringContainsString('<td :colspan="25"', $source);
|
||||
$this->assertStringContainsString('v-if="error" class="tb-error"', $source);
|
||||
$this->assertStringContainsString('e.response.data.message', $source);
|
||||
$this->assertStringContainsString('<label>平台渠道</label>', $source);
|
||||
$this->assertStringContainsString('@click="exportRows">导出</button>', $source);
|
||||
$this->assertStringContainsString('收支净额', $source);
|
||||
$this->assertStringContainsString('v-for="(group, index) in visibleGroups"', $source);
|
||||
$this->assertStringContainsString(':colspan="visibleColumns.length"', $source);
|
||||
$this->assertStringContainsString('v-if="error" class="tw-alert"', $source);
|
||||
$this->assertStringContainsString('error.response.data.message', $source);
|
||||
$this->assertStringContainsString('支付宝账号', $source);
|
||||
$this->assertStringContainsString('@click="exportRows"', $source);
|
||||
$this->assertStringContainsString('商品名称', $source);
|
||||
$this->assertStringContainsString('余额', $source);
|
||||
$this->assertStringNotContainsString('<th>财务备注</th>', $source);
|
||||
$this->assertStringNotContainsString('<th>订单号</th>', $source);
|
||||
$this->assertStringNotContainsString('<th class="tb-num">金额</th>', $source);
|
||||
|
||||
25
tests/scripts/frontend-cache-integrity.test.ps1
Normal file
25
tests/scripts/frontend-cache-integrity.test.ps1
Normal file
@ -0,0 +1,25 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$source = Join-Path $PSScriptRoot '../../scripts/build-frontend.ps1'
|
||||
$ast = [Management.Automation.Language.Parser]::ParseFile((Resolve-Path $source), [ref]$null, [ref]$null)
|
||||
$function = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Get-PathFingerprint' }, $true)
|
||||
Invoke-Expression $function.Extent.Text
|
||||
$check = $ast.Find({ param($node) $node -is [Management.Automation.Language.AssignmentStatementAst] -and $node.Left.Extent.Text -eq '$outputCacheValid' }, $true).Right.Extent.Text
|
||||
$repoRoot = Join-Path ([IO.Path]::GetTempPath()) ('caiwu-cache-test-' + [guid]::NewGuid().ToString('N'))
|
||||
$frontendOutputRoot = Join-Path $repoRoot '.caiwu-build/public'
|
||||
$outputFingerprintPath = Join-Path $repoRoot 'output.sha'
|
||||
New-Item -ItemType Directory -Path $frontendOutputRoot -Force | Out-Null
|
||||
try {
|
||||
$asset = Join-Path $frontendOutputRoot 'pages.js'
|
||||
Set-Content -LiteralPath $asset -Value 'new calendar'
|
||||
if (Invoke-Expression $check) { throw 'Missing integrity stamp was accepted.' }
|
||||
Set-Content -LiteralPath $outputFingerprintPath -Value (Get-PathFingerprint @('.caiwu-build/public'))
|
||||
if (-not (Invoke-Expression $check)) { throw 'Valid output was rejected.' }
|
||||
Set-Content -LiteralPath $asset -Value 'old calendar'
|
||||
if (Invoke-Expression $check) { throw 'Overwritten output was accepted.' }
|
||||
Remove-Item -LiteralPath $asset
|
||||
if (Invoke-Expression $check) { throw 'Missing output was accepted.' }
|
||||
Write-Host 'PASS missing stamp, valid cache, overwritten and missing asset detection'
|
||||
} finally {
|
||||
if ((Split-Path $repoRoot -Leaf) -notlike 'caiwu-cache-test-*') { throw 'Unexpected test directory.' }
|
||||
Remove-Item -LiteralPath $repoRoot -Recurse -Force
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user