fix(controller): списывать скоропорт с отступом после истечения срока (post_expiry_grace_days)

Возвращён регулируемый параметр списания, но с корректной семантикой: товар
попадает в проект списания, только пролежав N дней ПОСЛЕ истечения срока
(expiry_date <= now - grace), default 7. Свежепросроченное ещё может быть
забрано получателем — списываем лишь окончательно испорченное. Крон остаётся
ежемесячным (каждое 1-е число).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ant
2026-05-26 11:53:36 +00:00
parent 55d7289e0a
commit 6882ea23f5
4 changed files with 37 additions and 14 deletions
@@ -172,6 +172,7 @@ interface IConfig {
// Настройки автосписания скоропорта (видимы в форме установки).
writeoff: {
auto_proposal_enabled: boolean; // Автоформирование проекта списания (по умолчанию вкл.)
post_expiry_grace_days: number; // Списывать спустя N дней после истечения срока (по умолчанию 7)
};
}
```
@@ -22,11 +22,13 @@ import { MARKETPLACE_WRITEOFF_DRAFT_BUILT_EVENT } from '../events/marketplace-no
* Раз в месяц проходит по `marketplace_inventory.expiry_date`. Если в
* настройках расширения `writeoff.auto_proposal_enabled = true` —
* формирует DRAFT-проект списания со всеми позициями, у которых срок
* годности УЖЕ истёк (`expiry_date <= now`). Иначе — только эмитит
* напоминание председателю, чтобы тот собрал корзину вручную.
* годности истёк уже как минимум `writeoff.post_expiry_grace_days` дней
* назад (`expiry_date <= now - grace`). Иначе — только эмитит напоминание
* председателю, чтобы тот собрал корзину вручную.
*
* Списываем по факту порчи, а не заранее: в кандидаты попадает только
* непригодный товар (срок годности прошёл).
* Списываем по факту порчи, а не заранее, и с отступом: свежепросроченное
* ещё может быть забрано получателем — в кандидаты идёт только то, что
* пролежало просроченным достаточно долго и окончательно испортилось.
*/
@Injectable()
export class MarketplaceWriteoffCronService implements OnModuleInit {
@@ -71,6 +73,7 @@ export class MarketplaceWriteoffCronService implements OnModuleInit {
const extension = this.extensionDomainService ? await this.extensionDomainService.getAppByName('market') : null;
const cfg = extension?.config as IConfig | undefined;
const auto = cfg?.writeoff?.auto_proposal_enabled ?? true;
const graceDays = cfg?.writeoff?.post_expiry_grace_days ?? 7;
if (!auto) {
this.logger.info(
@@ -88,12 +91,12 @@ export class MarketplaceWriteoffCronService implements OnModuleInit {
// EXCESS_RETURNED_TO_WAREHOUSE, items «без юр. оформления» старше
// threshold writeoff_returned_age_days) — Phase 2: требуется
// расширение marketplace_inventory.status + поля returned_at / age_days.
const now = new Date();
const cutoff = new Date(Date.now() - graceDays * 86_400_000);
const candidates = await this.inventoryRepo.find({
where: {
coopname,
status: 'LABELED',
expiry_date: LessThanOrEqual(now),
expiry_date: LessThanOrEqual(cutoff),
},
take: 200,
order: { expiry_date: 'ASC' },
@@ -101,7 +104,7 @@ export class MarketplaceWriteoffCronService implements OnModuleInit {
if (candidates.length === 0) {
this.logger.info(
`[WRITEOFF_CRON] не найдено позиций с истёкшим сроком годности (coopname=${coopname})`
`[WRITEOFF_CRON] не найдено позиций, просроченных более чем на ${graceDays} дн. (coopname=${coopname})`
);
return;
}
@@ -70,7 +70,8 @@ export interface MarketplaceInventoryProps {
* Story 8.3 (Эпик 8): срок годности позиции. Проставляется при
* маркировке как `labeled_at + Offer.warranty_days * 86400`. По нему
* крон Эпика 8 формирует DRAFT-проект списания, когда срок годности
* истёк (`expiry_date <= now`).
* истёк уже более чем `writeoff.post_expiry_grace_days` дней назад
* (`expiry_date <= now - grace`).
*
* Nullable: для исторических записей без warranty_days и для позиций
* с бессрочными офферами (`warranty_days = 0`).
@@ -32,15 +32,19 @@ export interface ICoopAcceptanceConfig {
* Story 8.3 (Эпик 8): настройки авто-проекта списания скоропорта.
*
* `auto_proposal_enabled` — если true, ежемесячный крон собирает позиции с
* УЖЕ истёкшим сроком годности (`MarketplaceInventory.expiry_date <= now`) в
* DRAFT-проект и шлёт нотификацию председателю на ревью. Если false, крон
* только пушит напоминание; председатель формирует корзину вручную.
* давно истёкшим сроком годности в DRAFT-проект и шлёт нотификацию
* председателю на ревью. Если false, крон только пушит напоминание;
* председатель формирует корзину вручную.
*
* Списываем по факту порчи (срок годности истёк), а не заранее: на списание
* попадает только то, что уже непригодно.
* `post_expiry_grace_days` — сколько дней товар должен пролежать ПОСЛЕ
* истечения срока годности, прежде чем попасть в кандидаты на списание.
* Крон отбирает позиции с `expiry_date <= now - post_expiry_grace_days`.
* Свежепросроченное ещё может быть забрано получателем — списываем только
* окончательно испорченное (пролежавшее лишнее время).
*/
export interface IWriteoffConfig {
auto_proposal_enabled: boolean;
post_expiry_grace_days: number;
}
// Конфигурация для расширения marketplace
@@ -62,6 +66,7 @@ export const defaultConfig: IConfig = {
},
writeoff: {
auto_proposal_enabled: true,
post_expiry_grace_days: 7,
},
};
@@ -99,8 +104,21 @@ export const Schema = z.object({
note: 'Если включено, раз в месяц собирается проект списания товаров с истёкшим сроком годности и отправляется председателю на ревью. Если выключено — приходит только напоминание.',
})
),
post_expiry_grace_days: z
.number()
.int()
.min(0)
.default(7)
.describe(
describeField({
label: 'Списывать спустя (после истечения срока)',
note: 'Сколько дней товар должен пролежать после истечения срока годности, прежде чем попадёт в проект списания. Свежепросроченное ещё может быть забрано получателем — списываем только окончательно испорченное.',
rules: ['val >= 0'],
append: 'дн.',
})
),
})
.default({ auto_proposal_enabled: true })
.default({ auto_proposal_enabled: true, post_expiry_grace_days: 7 })
.describe(
describeField({
label: 'Списание скоропорта',