feat(old-items): complete reuse workflow and refine miniapp home
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
ALTER TABLE `files` MODIFY `business_type` ENUM('SERVICE_RECORD','PROJECT_COVER','ACTIVITY_COVER','CONTENT_COVER','OLD_ITEM_DONATION_IMAGE') NOT NULL;
|
||||
|
||||
CREATE TABLE `old_item_drop_off_points` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `public_id` CHAR(36) NOT NULL,
|
||||
`name` VARCHAR(120) NOT NULL, `region_text` VARCHAR(120) NOT NULL,
|
||||
`address_text` VARCHAR(300) NOT NULL, `opening_hours` VARCHAR(200) NOT NULL,
|
||||
`is_active` BOOLEAN NOT NULL DEFAULT true, `sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL, `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL, UNIQUE INDEX `old_item_drop_off_points_public_id_key` (`public_id`),
|
||||
INDEX `old_item_drop_off_points_active_sort_idx` (`is_active`,`sort_order`), PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_needs` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `public_id` CHAR(36) NOT NULL,
|
||||
`client_request_id` CHAR(36) NOT NULL, `request_fingerprint` CHAR(64) NOT NULL,
|
||||
`title` VARCHAR(160) NOT NULL, `category` ENUM('CLOTHING','BOOKS','HOUSEHOLD','SPORTS') NOT NULL,
|
||||
`region_text` VARCHAR(120) NOT NULL, `beneficiary_group` VARCHAR(160) NOT NULL, `description` TEXT NOT NULL,
|
||||
`requested_quantity` INTEGER UNSIGNED NOT NULL, `fulfilled_quantity` INTEGER UNSIGNED NOT NULL DEFAULT 0,
|
||||
`priority` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`status` ENUM('DRAFT','PUBLISHED','FULFILLED','CLOSED') NOT NULL DEFAULT 'DRAFT',
|
||||
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1, `published_at` DATETIME(3) NULL,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL, `updated_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `old_item_needs_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `old_item_needs_creator_request_key` (`created_by`,`client_request_id`),
|
||||
INDEX `old_item_needs_public_list_idx` (`status`,`category`,`priority`,`sort_order`), PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_need_status_history` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `need_id` BIGINT UNSIGNED NOT NULL,
|
||||
`request_id` CHAR(36) NOT NULL, `request_fingerprint` CHAR(64) NOT NULL,
|
||||
`from_status` ENUM('DRAFT','PUBLISHED','FULFILLED','CLOSED') NULL,
|
||||
`to_status` ENUM('DRAFT','PUBLISHED','FULFILLED','CLOSED') NOT NULL,
|
||||
`operator_user_id` BIGINT UNSIGNED NOT NULL, `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `old_item_need_status_history_request_id_key` (`request_id`),
|
||||
INDEX `old_item_need_history_need_created_idx` (`need_id`,`created_at`), PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_donations` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `public_id` CHAR(36) NOT NULL, `donation_no` VARCHAR(32) NOT NULL,
|
||||
`donor_user_id` BIGINT UNSIGNED NOT NULL, `client_request_id` CHAR(36) NOT NULL, `request_fingerprint` CHAR(64) NOT NULL,
|
||||
`category` ENUM('CLOTHING','BOOKS','HOUSEHOLD','SPORTS') NOT NULL,
|
||||
`item_condition` ENUM('NEW','LIKE_NEW','GOOD','FAIR') NOT NULL, `quantity` INTEGER UNSIGNED NOT NULL,
|
||||
`description_encrypted` TEXT NOT NULL, `method` ENUM('PICKUP','DROP_OFF') NOT NULL,
|
||||
`contact_name_encrypted` VARCHAR(512) NOT NULL, `mobile_encrypted` VARCHAR(512) NOT NULL, `mobile_hash` CHAR(64) NOT NULL,
|
||||
`address_encrypted` TEXT NULL, `appointment_at` DATETIME(3) NULL, `drop_off_point_id` BIGINT UNSIGNED NULL,
|
||||
`target_need_id` BIGINT UNSIGNED NULL, `assigned_user_id` BIGINT UNSIGNED NULL,
|
||||
`rejection_reason_encrypted` TEXT NULL,
|
||||
`status` ENUM('SUBMITTED','REJECTED','WAITING_PICKUP','WAITING_DROP_OFF','RECEIVED','PROCESSING','COMPLETED','CANCELLED') NOT NULL DEFAULT 'SUBMITTED',
|
||||
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1, `assisted_people` INTEGER UNSIGNED NOT NULL DEFAULT 0,
|
||||
`received_at` DATETIME(3) NULL, `completed_at` DATETIME(3) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `old_item_donations_public_id_key` (`public_id`), UNIQUE INDEX `old_item_donations_donation_no_key` (`donation_no`),
|
||||
UNIQUE INDEX `old_item_donations_donor_request_key` (`donor_user_id`,`client_request_id`),
|
||||
INDEX `old_item_donations_donor_status_created_idx` (`donor_user_id`,`status`,`created_at`),
|
||||
INDEX `old_item_donations_admin_list_idx` (`status`,`method`,`created_at`),
|
||||
INDEX `old_item_donations_need_status_idx` (`target_need_id`,`status`), PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_donation_status_history` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `donation_id` BIGINT UNSIGNED NOT NULL,
|
||||
`request_id` CHAR(36) NOT NULL, `request_fingerprint` CHAR(64) NOT NULL,
|
||||
`from_status` ENUM('SUBMITTED','REJECTED','WAITING_PICKUP','WAITING_DROP_OFF','RECEIVED','PROCESSING','COMPLETED','CANCELLED') NULL,
|
||||
`to_status` ENUM('SUBMITTED','REJECTED','WAITING_PICKUP','WAITING_DROP_OFF','RECEIVED','PROCESSING','COMPLETED','CANCELLED') NOT NULL,
|
||||
`note_encrypted` TEXT NULL, `operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `old_item_donation_status_history_request_id_key` (`request_id`),
|
||||
INDEX `old_item_donation_history_donation_created_idx` (`donation_id`,`created_at`), PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_donation_files` (
|
||||
`donation_id` BIGINT UNSIGNED NOT NULL, `file_id` BIGINT UNSIGNED NOT NULL,
|
||||
`sort_order` INTEGER NOT NULL DEFAULT 0, `attached_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
INDEX `old_item_donation_files_file_idx` (`file_id`), PRIMARY KEY (`donation_id`,`file_id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `old_item_drop_off_points` ADD CONSTRAINT `old_item_drop_off_points_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_needs` ADD CONSTRAINT `old_item_needs_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_needs` ADD CONSTRAINT `old_item_needs_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_need_status_history` ADD CONSTRAINT `old_item_need_history_need_fkey` FOREIGN KEY (`need_id`) REFERENCES `old_item_needs`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_need_status_history` ADD CONSTRAINT `old_item_need_history_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_donations` ADD CONSTRAINT `old_item_donations_donor_fkey` FOREIGN KEY (`donor_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_donations` ADD CONSTRAINT `old_item_donations_assignee_fkey` FOREIGN KEY (`assigned_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_donations` ADD CONSTRAINT `old_item_donations_drop_off_point_fkey` FOREIGN KEY (`drop_off_point_id`) REFERENCES `old_item_drop_off_points`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_donations` ADD CONSTRAINT `old_item_donations_target_need_fkey` FOREIGN KEY (`target_need_id`) REFERENCES `old_item_needs`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_donation_status_history` ADD CONSTRAINT `old_item_donation_history_donation_fkey` FOREIGN KEY (`donation_id`) REFERENCES `old_item_donations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_donation_status_history` ADD CONSTRAINT `old_item_donation_history_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_donation_files` ADD CONSTRAINT `old_item_donation_files_donation_fkey` FOREIGN KEY (`donation_id`) REFERENCES `old_item_donations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_donation_files` ADD CONSTRAINT `old_item_donation_files_file_fkey` FOREIGN KEY (`file_id`) REFERENCES `files`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO `permissions` (`code`,`name`,`description`) VALUES
|
||||
('old_items:read','查看旧物善用','查看旧物捐赠、需求与统计'),
|
||||
('old_items:manage','管理旧物善用','审核、分配并更新旧物捐赠状态'),
|
||||
('old_item_needs:manage','管理旧物需求','发布、编辑和下架物资需求');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`,`permission_id`)
|
||||
SELECT r.id,p.id FROM `roles` r CROSS JOIN `permissions` p
|
||||
WHERE r.code IN ('ADMIN','SUPER_ADMIN') AND p.code IN ('old_items:read','old_items:manage','old_item_needs:manage');
|
||||
INSERT INTO `role_permissions` (`role_id`,`permission_id`)
|
||||
SELECT r.id,p.id FROM `roles` r CROSS JOIN `permissions` p
|
||||
WHERE r.code IN ('STAFF','PROJECT_MANAGER') AND p.code IN ('old_items:read','old_items:manage')
|
||||
ON DUPLICATE KEY UPDATE `role_id`=`role_id`;
|
||||
@@ -0,0 +1,89 @@
|
||||
CREATE TABLE `old_item_inventory_imports` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`client_request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`original_filename_encrypted` VARCHAR(512) NOT NULL,
|
||||
`row_count` INTEGER UNSIGNED NOT NULL,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `old_item_inventory_imports_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `old_item_inventory_import_creator_request_key` (`created_by`, `client_request_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_inventory_items` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`inventory_no` VARCHAR(32) NOT NULL,
|
||||
`source_donation_id` BIGINT UNSIGNED NULL,
|
||||
`import_id` BIGINT UNSIGNED NULL,
|
||||
`category` ENUM('CLOTHING','BOOKS','HOUSEHOLD','SPORTS') NOT NULL,
|
||||
`item_name` VARCHAR(160) NOT NULL,
|
||||
`quantity` INTEGER UNSIGNED NOT NULL,
|
||||
`unit` VARCHAR(20) NOT NULL DEFAULT '件',
|
||||
`storage_location` VARCHAR(160) NOT NULL,
|
||||
`status` ENUM('IN_STOCK','SORTED','DISINFECTED','OUTBOUND','DELIVERED') NOT NULL DEFAULT 'IN_STOCK',
|
||||
`target_need_id` BIGINT UNSIGNED NULL,
|
||||
`beneficiary_id` BIGINT UNSIGNED NULL,
|
||||
`destination_region` VARCHAR(120) NULL,
|
||||
`note_encrypted` TEXT NULL,
|
||||
`landing_at` DATETIME(3) NULL,
|
||||
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL,
|
||||
`updated_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `old_item_inventory_items_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `old_item_inventory_items_inventory_no_key` (`inventory_no`),
|
||||
UNIQUE INDEX `old_item_inventory_items_source_donation_id_key` (`source_donation_id`),
|
||||
INDEX `old_item_inventory_status_category_created_idx` (`status`, `category`, `created_at`),
|
||||
INDEX `old_item_inventory_need_status_idx` (`target_need_id`, `status`),
|
||||
INDEX `old_item_inventory_beneficiary_status_idx` (`beneficiary_id`, `status`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_inventory_events` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`inventory_item_id` BIGINT UNSIGNED NOT NULL,
|
||||
`request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`event_type` ENUM('INBOUND','SORTED','DISINFECTED','OUTBOUND','DELIVERED') NOT NULL,
|
||||
`from_status` ENUM('IN_STOCK','SORTED','DISINFECTED','OUTBOUND','DELIVERED') NULL,
|
||||
`to_status` ENUM('IN_STOCK','SORTED','DISINFECTED','OUTBOUND','DELIVERED') NOT NULL,
|
||||
`location_text` VARCHAR(160) NULL,
|
||||
`note_encrypted` TEXT NULL,
|
||||
`event_at` DATETIME(3) NOT NULL,
|
||||
`operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `old_item_inventory_events_request_id_key` (`request_id`),
|
||||
INDEX `old_item_inventory_events_item_event_idx` (`inventory_item_id`, `event_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `old_item_inventory_imports` ADD CONSTRAINT `old_item_inventory_imports_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_source_donation_fkey` FOREIGN KEY (`source_donation_id`) REFERENCES `old_item_donations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_import_fkey` FOREIGN KEY (`import_id`) REFERENCES `old_item_inventory_imports`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_target_need_fkey` FOREIGN KEY (`target_need_id`) REFERENCES `old_item_needs`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_beneficiary_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_inventory_events` ADD CONSTRAINT `old_item_inventory_events_item_fkey` FOREIGN KEY (`inventory_item_id`) REFERENCES `old_item_inventory_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_inventory_events` ADD CONSTRAINT `old_item_inventory_events_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
|
||||
('old_item_inventory:read', '查看旧物台账', '查看旧物物资全流程台账'),
|
||||
('old_item_inventory:manage', '管理旧物台账', '入库并更新旧物物资流转状态'),
|
||||
('old_item_inventory:import', '导入旧物台账', '从标准 Excel 模板批量导入物资'),
|
||||
('old_item_inventory:export', '导出旧物台账', '导出和打印最小化旧物台账');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
|
||||
WHERE r.code IN ('ADMIN','SUPER_ADMIN') AND p.code IN (
|
||||
'old_item_inventory:read','old_item_inventory:manage','old_item_inventory:import','old_item_inventory:export'
|
||||
);
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
|
||||
WHERE r.code IN ('STAFF','PROJECT_MANAGER') AND p.code IN ('old_item_inventory:read','old_item_inventory:manage')
|
||||
ON DUPLICATE KEY UPDATE `role_id` = `role_id`;
|
||||
@@ -0,0 +1,113 @@
|
||||
CREATE TABLE `old_item_reward_configs` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`client_request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`version` INTEGER UNSIGNED NOT NULL,
|
||||
`active_key` VARCHAR(16) NULL,
|
||||
`certificate_title` VARCHAR(120) NOT NULL,
|
||||
`certificate_message` VARCHAR(1000) NOT NULL,
|
||||
`issuer_name` VARCHAR(120) NOT NULL,
|
||||
`legal_notice` VARCHAR(500) NOT NULL,
|
||||
`clothing_points` INTEGER UNSIGNED NOT NULL,
|
||||
`books_points` INTEGER UNSIGNED NOT NULL,
|
||||
`household_points` INTEGER UNSIGNED NOT NULL,
|
||||
`sports_points` INTEGER UNSIGNED NOT NULL,
|
||||
`max_points_per_donation` INTEGER UNSIGNED NOT NULL,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `old_item_reward_configs_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `old_item_reward_configs_client_request_id_key` (`client_request_id`),
|
||||
UNIQUE INDEX `old_item_reward_configs_version_key` (`version`),
|
||||
UNIQUE INDEX `old_item_reward_configs_active_key_key` (`active_key`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_certificates` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`certificate_no` VARCHAR(40) NOT NULL,
|
||||
`donation_id` BIGINT UNSIGNED NOT NULL,
|
||||
`config_id` BIGINT UNSIGNED NULL,
|
||||
`issue_request_id` CHAR(36) NOT NULL,
|
||||
`issue_fingerprint` CHAR(64) NOT NULL,
|
||||
`active_key` VARCHAR(16) NULL,
|
||||
`status` ENUM('ISSUED','REVOKED') NOT NULL DEFAULT 'ISSUED',
|
||||
`recipient_name_encrypted` VARCHAR(512) NOT NULL,
|
||||
`title_snapshot` VARCHAR(120) NOT NULL,
|
||||
`message_snapshot` VARCHAR(1000) NOT NULL,
|
||||
`issuer_snapshot` VARCHAR(120) NOT NULL,
|
||||
`legal_notice_snapshot` VARCHAR(500) NOT NULL,
|
||||
`issued_by` BIGINT UNSIGNED NOT NULL,
|
||||
`issued_at` DATETIME(3) NOT NULL,
|
||||
`revoked_by` BIGINT UNSIGNED NULL,
|
||||
`revoked_request_id` CHAR(36) NULL,
|
||||
`revoked_fingerprint` CHAR(64) NULL,
|
||||
`revoke_reason_encrypted` TEXT NULL,
|
||||
`revoked_at` DATETIME(3) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `old_item_certificates_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `old_item_certificates_certificate_no_key` (`certificate_no`),
|
||||
UNIQUE INDEX `old_item_certificates_issue_request_id_key` (`issue_request_id`),
|
||||
UNIQUE INDEX `old_item_certificates_revoked_request_id_key` (`revoked_request_id`),
|
||||
UNIQUE INDEX `old_item_certificates_donation_active_key` (`donation_id`, `active_key`),
|
||||
INDEX `old_item_certificates_donation_issued_idx` (`donation_id`, `issued_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_point_accounts` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`balance` INTEGER UNSIGNED NOT NULL DEFAULT 0,
|
||||
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `old_item_point_accounts_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `old_item_point_accounts_user_id_key` (`user_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_point_entries` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`account_id` BIGINT UNSIGNED NOT NULL,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`donation_id` BIGINT UNSIGNED NULL,
|
||||
`config_id` BIGINT UNSIGNED NULL,
|
||||
`request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`source_key` VARCHAR(100) NOT NULL,
|
||||
`entry_type` ENUM('EARNED','ADJUSTMENT') NOT NULL,
|
||||
`points_delta` INTEGER NOT NULL,
|
||||
`balance_after` INTEGER UNSIGNED NOT NULL,
|
||||
`note_encrypted` TEXT NULL,
|
||||
`operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `old_item_point_entries_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `old_item_point_entries_request_id_key` (`request_id`),
|
||||
UNIQUE INDEX `old_item_point_entries_source_key_key` (`source_key`),
|
||||
INDEX `old_item_point_entries_user_created_idx` (`user_id`, `created_at`),
|
||||
INDEX `old_item_point_entries_donation_idx` (`donation_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `old_item_reward_configs` ADD CONSTRAINT `old_item_reward_configs_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_certificates` ADD CONSTRAINT `old_item_certificates_donation_fkey` FOREIGN KEY (`donation_id`) REFERENCES `old_item_donations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_certificates` ADD CONSTRAINT `old_item_certificates_config_fkey` FOREIGN KEY (`config_id`) REFERENCES `old_item_reward_configs`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_certificates` ADD CONSTRAINT `old_item_certificates_issued_by_fkey` FOREIGN KEY (`issued_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_certificates` ADD CONSTRAINT `old_item_certificates_revoked_by_fkey` FOREIGN KEY (`revoked_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_point_accounts` ADD CONSTRAINT `old_item_point_accounts_user_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_point_entries` ADD CONSTRAINT `old_item_point_entries_account_fkey` FOREIGN KEY (`account_id`) REFERENCES `old_item_point_accounts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_point_entries` ADD CONSTRAINT `old_item_point_entries_user_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_point_entries` ADD CONSTRAINT `old_item_point_entries_donation_fkey` FOREIGN KEY (`donation_id`) REFERENCES `old_item_donations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_point_entries` ADD CONSTRAINT `old_item_point_entries_config_fkey` FOREIGN KEY (`config_id`) REFERENCES `old_item_reward_configs`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_point_entries` ADD CONSTRAINT `old_item_point_entries_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
|
||||
('old_item_rewards:read', '查看旧物证书积分', '查看旧物善用证书、积分账户和规则'),
|
||||
('old_item_rewards:manage', '管理旧物证书积分', '配置规则、补发撤销证书和调整公益积分');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
|
||||
WHERE r.code IN ('ADMIN','SUPER_ADMIN') AND p.code IN ('old_item_rewards:read','old_item_rewards:manage');
|
||||
+529
-133
@@ -153,6 +153,70 @@ enum FileBusinessType {
|
||||
PROJECT_COVER
|
||||
ACTIVITY_COVER
|
||||
CONTENT_COVER
|
||||
OLD_ITEM_DONATION_IMAGE
|
||||
}
|
||||
|
||||
enum OldItemCategory {
|
||||
CLOTHING
|
||||
BOOKS
|
||||
HOUSEHOLD
|
||||
SPORTS
|
||||
}
|
||||
|
||||
enum OldItemCondition {
|
||||
NEW
|
||||
LIKE_NEW
|
||||
GOOD
|
||||
FAIR
|
||||
}
|
||||
|
||||
enum OldItemDonationMethod {
|
||||
PICKUP
|
||||
DROP_OFF
|
||||
}
|
||||
|
||||
enum OldItemDonationStatus {
|
||||
SUBMITTED
|
||||
REJECTED
|
||||
WAITING_PICKUP
|
||||
WAITING_DROP_OFF
|
||||
RECEIVED
|
||||
PROCESSING
|
||||
COMPLETED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum OldItemNeedStatus {
|
||||
DRAFT
|
||||
PUBLISHED
|
||||
FULFILLED
|
||||
CLOSED
|
||||
}
|
||||
|
||||
enum OldItemInventoryStatus {
|
||||
IN_STOCK
|
||||
SORTED
|
||||
DISINFECTED
|
||||
OUTBOUND
|
||||
DELIVERED
|
||||
}
|
||||
|
||||
enum OldItemInventoryEventType {
|
||||
INBOUND
|
||||
SORTED
|
||||
DISINFECTED
|
||||
OUTBOUND
|
||||
DELIVERED
|
||||
}
|
||||
|
||||
enum OldItemCertificateStatus {
|
||||
ISSUED
|
||||
REVOKED
|
||||
}
|
||||
|
||||
enum OldItemPointEntryType {
|
||||
EARNED
|
||||
ADJUSTMENT
|
||||
}
|
||||
|
||||
enum ContentPostType {
|
||||
@@ -261,70 +325,87 @@ enum FollowUpSource {
|
||||
}
|
||||
|
||||
model User {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
status UserStatus @default(ACTIVE)
|
||||
userType UserType @map("user_type")
|
||||
mobileEncrypted String? @map("mobile_encrypted") @db.VarChar(512)
|
||||
mobileHash String? @unique @map("mobile_hash") @db.Char(64)
|
||||
nickname String? @db.VarChar(100)
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar(512)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
wechatIdentities WechatIdentity[]
|
||||
staffProfile StaffProfile?
|
||||
adminCredential AdminCredential?
|
||||
sessions AuthSession[]
|
||||
userRoles UserRole[]
|
||||
auditLogs AuditLog[] @relation("AuditActor")
|
||||
assignedRoles UserRole[] @relation("UserRoleAssigner")
|
||||
serviceApplications ServiceApplication[] @relation("ApplicationApplicant")
|
||||
applicationStatusChanges ServiceApplicationStatusHistory[] @relation("ApplicationStatusOperator")
|
||||
consents Consent[]
|
||||
createdBeneficiaries BeneficiaryProfile[] @relation("BeneficiaryCreator")
|
||||
updatedBeneficiaries BeneficiaryProfile[] @relation("BeneficiaryUpdater")
|
||||
ownedServiceCases ServiceCase[] @relation("CaseOwner")
|
||||
createdServiceCases ServiceCase[] @relation("CaseCreator")
|
||||
updatedServiceCases ServiceCase[] @relation("CaseUpdater")
|
||||
caseAssignments CaseAssignee[]
|
||||
caseStatusChanges ServiceCaseStatusHistory[] @relation("CaseStatusOperator")
|
||||
createdServiceRecords ServiceRecord[] @relation("ServiceRecordCreator")
|
||||
updatedServiceRecords ServiceRecord[] @relation("ServiceRecordUpdater")
|
||||
uploadedFiles File[] @relation("FileUploader")
|
||||
assignedBeneficiaryTags BeneficiaryTag[] @relation("BeneficiaryTagAssigner")
|
||||
assignedCaseMembers CaseAssignee[] @relation("CaseAssigneeAssigner")
|
||||
revisedServiceRecords ServiceRecordRevision[] @relation("ServiceRecordReviser")
|
||||
ownedFollowUpTasks FollowUpTask[] @relation("FollowUpOwner")
|
||||
createdFollowUpTasks FollowUpTask[] @relation("FollowUpCreator")
|
||||
updatedFollowUpTasks FollowUpTask[] @relation("FollowUpUpdater")
|
||||
followUpStatusChanges FollowUpStatusHistory[] @relation("FollowUpStatusOperator")
|
||||
volunteerProfile Volunteer?
|
||||
reviewedVolunteers Volunteer[] @relation("VolunteerReviewer")
|
||||
createdVolunteers Volunteer[] @relation("VolunteerCreator")
|
||||
updatedVolunteers Volunteer[] @relation("VolunteerUpdater")
|
||||
volunteerStatusChanges VolunteerStatusHistory[] @relation("VolunteerStatusOperator")
|
||||
managedProjects Project[] @relation("ProjectManager")
|
||||
createdProjects Project[] @relation("ProjectCreator")
|
||||
updatedProjects Project[] @relation("ProjectUpdater")
|
||||
projectStatusChanges ProjectStatusHistory[] @relation("ProjectStatusOperator")
|
||||
createdActivities Activity[] @relation("ActivityCreator")
|
||||
updatedActivities Activity[] @relation("ActivityUpdater")
|
||||
activityStatusChanges ActivityStatusHistory[] @relation("ActivityStatusOperator")
|
||||
activityRegistrations ActivityRegistration[] @relation("ActivityRegistrationUser")
|
||||
reviewedRegistrations ActivityRegistration[] @relation("RegistrationReviewer")
|
||||
checkedInRegistrations ActivityRegistration[] @relation("RegistrationCheckInOperator")
|
||||
recordedServiceMinutes ActivityRegistration[] @relation("RegistrationMinutesOperator")
|
||||
registrationChanges ActivityRegistrationHistory[] @relation("RegistrationHistoryOperator")
|
||||
createdContentPosts ContentPost[] @relation("ContentCreator")
|
||||
updatedContentPosts ContentPost[] @relation("ContentUpdater")
|
||||
reviewedContentPosts ContentPost[] @relation("ContentReviewer")
|
||||
contentStatusChanges ContentPostStatusHistory[] @relation("ContentStatusOperator")
|
||||
notifications Notification[]
|
||||
notificationPreferences NotificationPreference[]
|
||||
createdFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureCreator")
|
||||
updatedFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureUpdater")
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
status UserStatus @default(ACTIVE)
|
||||
userType UserType @map("user_type")
|
||||
mobileEncrypted String? @map("mobile_encrypted") @db.VarChar(512)
|
||||
mobileHash String? @unique @map("mobile_hash") @db.Char(64)
|
||||
nickname String? @db.VarChar(100)
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar(512)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
wechatIdentities WechatIdentity[]
|
||||
staffProfile StaffProfile?
|
||||
adminCredential AdminCredential?
|
||||
sessions AuthSession[]
|
||||
userRoles UserRole[]
|
||||
auditLogs AuditLog[] @relation("AuditActor")
|
||||
assignedRoles UserRole[] @relation("UserRoleAssigner")
|
||||
serviceApplications ServiceApplication[] @relation("ApplicationApplicant")
|
||||
applicationStatusChanges ServiceApplicationStatusHistory[] @relation("ApplicationStatusOperator")
|
||||
consents Consent[]
|
||||
createdBeneficiaries BeneficiaryProfile[] @relation("BeneficiaryCreator")
|
||||
updatedBeneficiaries BeneficiaryProfile[] @relation("BeneficiaryUpdater")
|
||||
ownedServiceCases ServiceCase[] @relation("CaseOwner")
|
||||
createdServiceCases ServiceCase[] @relation("CaseCreator")
|
||||
updatedServiceCases ServiceCase[] @relation("CaseUpdater")
|
||||
caseAssignments CaseAssignee[]
|
||||
caseStatusChanges ServiceCaseStatusHistory[] @relation("CaseStatusOperator")
|
||||
createdServiceRecords ServiceRecord[] @relation("ServiceRecordCreator")
|
||||
updatedServiceRecords ServiceRecord[] @relation("ServiceRecordUpdater")
|
||||
uploadedFiles File[] @relation("FileUploader")
|
||||
assignedBeneficiaryTags BeneficiaryTag[] @relation("BeneficiaryTagAssigner")
|
||||
assignedCaseMembers CaseAssignee[] @relation("CaseAssigneeAssigner")
|
||||
revisedServiceRecords ServiceRecordRevision[] @relation("ServiceRecordReviser")
|
||||
ownedFollowUpTasks FollowUpTask[] @relation("FollowUpOwner")
|
||||
createdFollowUpTasks FollowUpTask[] @relation("FollowUpCreator")
|
||||
updatedFollowUpTasks FollowUpTask[] @relation("FollowUpUpdater")
|
||||
followUpStatusChanges FollowUpStatusHistory[] @relation("FollowUpStatusOperator")
|
||||
volunteerProfile Volunteer?
|
||||
reviewedVolunteers Volunteer[] @relation("VolunteerReviewer")
|
||||
createdVolunteers Volunteer[] @relation("VolunteerCreator")
|
||||
updatedVolunteers Volunteer[] @relation("VolunteerUpdater")
|
||||
volunteerStatusChanges VolunteerStatusHistory[] @relation("VolunteerStatusOperator")
|
||||
managedProjects Project[] @relation("ProjectManager")
|
||||
createdProjects Project[] @relation("ProjectCreator")
|
||||
updatedProjects Project[] @relation("ProjectUpdater")
|
||||
projectStatusChanges ProjectStatusHistory[] @relation("ProjectStatusOperator")
|
||||
createdActivities Activity[] @relation("ActivityCreator")
|
||||
updatedActivities Activity[] @relation("ActivityUpdater")
|
||||
activityStatusChanges ActivityStatusHistory[] @relation("ActivityStatusOperator")
|
||||
activityRegistrations ActivityRegistration[] @relation("ActivityRegistrationUser")
|
||||
reviewedRegistrations ActivityRegistration[] @relation("RegistrationReviewer")
|
||||
checkedInRegistrations ActivityRegistration[] @relation("RegistrationCheckInOperator")
|
||||
recordedServiceMinutes ActivityRegistration[] @relation("RegistrationMinutesOperator")
|
||||
registrationChanges ActivityRegistrationHistory[] @relation("RegistrationHistoryOperator")
|
||||
createdContentPosts ContentPost[] @relation("ContentCreator")
|
||||
updatedContentPosts ContentPost[] @relation("ContentUpdater")
|
||||
reviewedContentPosts ContentPost[] @relation("ContentReviewer")
|
||||
contentStatusChanges ContentPostStatusHistory[] @relation("ContentStatusOperator")
|
||||
notifications Notification[]
|
||||
notificationPreferences NotificationPreference[]
|
||||
createdFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureCreator")
|
||||
updatedFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureUpdater")
|
||||
financialDisclosureStatusChanges FinancialDisclosureStatusHistory[] @relation("FinancialDisclosureStatusOperator")
|
||||
oldItemDonations OldItemDonation[] @relation("OldItemDonor")
|
||||
assignedOldItemDonations OldItemDonation[] @relation("OldItemAssignee")
|
||||
oldItemDonationStatusChanges OldItemDonationStatusHistory[] @relation("OldItemDonationStatusOperator")
|
||||
createdOldItemNeeds OldItemNeed[] @relation("OldItemNeedCreator")
|
||||
updatedOldItemNeeds OldItemNeed[] @relation("OldItemNeedUpdater")
|
||||
oldItemNeedStatusChanges OldItemNeedStatusHistory[] @relation("OldItemNeedStatusOperator")
|
||||
createdOldItemDropOffPoints OldItemDropOffPoint[] @relation("OldItemDropOffPointCreator")
|
||||
createdOldItemInventoryItems OldItemInventoryItem[] @relation("OldItemInventoryCreator")
|
||||
updatedOldItemInventoryItems OldItemInventoryItem[] @relation("OldItemInventoryUpdater")
|
||||
oldItemInventoryEvents OldItemInventoryEvent[] @relation("OldItemInventoryEventOperator")
|
||||
oldItemInventoryImports OldItemInventoryImport[] @relation("OldItemInventoryImportCreator")
|
||||
createdOldItemRewardConfigs OldItemRewardConfig[] @relation("OldItemRewardConfigCreator")
|
||||
issuedOldItemCertificates OldItemCertificate[] @relation("OldItemCertificateIssuer")
|
||||
revokedOldItemCertificates OldItemCertificate[] @relation("OldItemCertificateRevoker")
|
||||
oldItemPointAccount OldItemPointAccount?
|
||||
oldItemPointEntries OldItemPointEntry[] @relation("OldItemPointOwner")
|
||||
operatedOldItemPointEntries OldItemPointEntry[] @relation("OldItemPointOperator")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -552,27 +633,27 @@ model ServiceApplicationStatusHistory {
|
||||
}
|
||||
|
||||
model BeneficiaryProfile {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
displayNameEncrypted String @map("display_name_encrypted") @db.VarChar(512)
|
||||
displayNameHash String @map("display_name_hash") @db.Char(64)
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
displayNameEncrypted String @map("display_name_encrypted") @db.VarChar(512)
|
||||
displayNameHash String @map("display_name_hash") @db.Char(64)
|
||||
gender BeneficiaryGender?
|
||||
birthYear Int? @map("birth_year") @db.UnsignedSmallInt
|
||||
mobileEncrypted String? @map("mobile_encrypted") @db.VarChar(512)
|
||||
mobileHash String? @map("mobile_hash") @db.Char(64)
|
||||
regionCode String @map("region_code") @db.VarChar(100)
|
||||
addressDetailEncrypted String? @map("address_detail_encrypted") @db.VarChar(1024)
|
||||
primaryCategory ServiceCategory @map("primary_category")
|
||||
keyCareFlag Boolean @default(false) @map("key_care_flag")
|
||||
isMinor Boolean @default(false) @map("is_minor")
|
||||
status BeneficiaryStatus @default(ACTIVE)
|
||||
notesEncrypted String? @map("notes_encrypted") @db.Text
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
creator User @relation("BeneficiaryCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
updater User @relation("BeneficiaryUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
|
||||
birthYear Int? @map("birth_year") @db.UnsignedSmallInt
|
||||
mobileEncrypted String? @map("mobile_encrypted") @db.VarChar(512)
|
||||
mobileHash String? @map("mobile_hash") @db.Char(64)
|
||||
regionCode String @map("region_code") @db.VarChar(100)
|
||||
addressDetailEncrypted String? @map("address_detail_encrypted") @db.VarChar(1024)
|
||||
primaryCategory ServiceCategory @map("primary_category")
|
||||
keyCareFlag Boolean @default(false) @map("key_care_flag")
|
||||
isMinor Boolean @default(false) @map("is_minor")
|
||||
status BeneficiaryStatus @default(ACTIVE)
|
||||
notesEncrypted String? @map("notes_encrypted") @db.Text
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
creator User @relation("BeneficiaryCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
updater User @relation("BeneficiaryUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
|
||||
contacts BeneficiaryContact[]
|
||||
beneficiaryTags BeneficiaryTag[]
|
||||
applications ServiceApplication[]
|
||||
@@ -580,6 +661,7 @@ model BeneficiaryProfile {
|
||||
serviceRecords ServiceRecord[]
|
||||
consents Consent[]
|
||||
followUpTasks FollowUpTask[]
|
||||
oldItemInventoryItems OldItemInventoryItem[]
|
||||
|
||||
@@index([displayNameHash], map: "beneficiary_profiles_display_name_hash_idx")
|
||||
@@index([mobileHash], map: "beneficiary_profiles_mobile_hash_idx")
|
||||
@@ -769,29 +851,30 @@ model ServiceRecordRevision {
|
||||
}
|
||||
|
||||
model File {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
caseId BigInt? @map("case_id") @db.UnsignedBigInt
|
||||
clientRequestId String @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
storageProvider String @map("storage_provider") @db.VarChar(50)
|
||||
bucket String @db.VarChar(255)
|
||||
objectKey String @unique @map("object_key") @db.VarChar(512)
|
||||
originalFilenameEncrypted String @map("original_filename_encrypted") @db.VarChar(1024)
|
||||
mimeType String @map("mime_type") @db.VarChar(100)
|
||||
size BigInt @db.UnsignedBigInt
|
||||
sha256 String? @db.Char(64)
|
||||
businessType FileBusinessType @map("business_type")
|
||||
uploaderUserId BigInt @map("uploader_user_id") @db.UnsignedBigInt
|
||||
status FileStatus @default(UPLOAD_PENDING)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
serviceCase ServiceCase? @relation(fields: [caseId], references: [id], onDelete: Restrict)
|
||||
uploader User @relation("FileUploader", fields: [uploaderUserId], references: [id], onDelete: Restrict)
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
caseId BigInt? @map("case_id") @db.UnsignedBigInt
|
||||
clientRequestId String @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
storageProvider String @map("storage_provider") @db.VarChar(50)
|
||||
bucket String @db.VarChar(255)
|
||||
objectKey String @unique @map("object_key") @db.VarChar(512)
|
||||
originalFilenameEncrypted String @map("original_filename_encrypted") @db.VarChar(1024)
|
||||
mimeType String @map("mime_type") @db.VarChar(100)
|
||||
size BigInt @db.UnsignedBigInt
|
||||
sha256 String? @db.Char(64)
|
||||
businessType FileBusinessType @map("business_type")
|
||||
uploaderUserId BigInt @map("uploader_user_id") @db.UnsignedBigInt
|
||||
status FileStatus @default(UPLOAD_PENDING)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
serviceCase ServiceCase? @relation(fields: [caseId], references: [id], onDelete: Restrict)
|
||||
uploader User @relation("FileUploader", fields: [uploaderUserId], references: [id], onDelete: Restrict)
|
||||
serviceRecords ServiceRecordFile[]
|
||||
projectCover Project? @relation("ProjectCover")
|
||||
activityCover Activity? @relation("ActivityCover")
|
||||
contentCover ContentPost? @relation("ContentCover")
|
||||
projectCover Project? @relation("ProjectCover")
|
||||
activityCover Activity? @relation("ActivityCover")
|
||||
contentCover ContentPost? @relation("ContentCover")
|
||||
oldItemDonations OldItemDonationFile[]
|
||||
|
||||
@@unique([uploaderUserId, clientRequestId], map: "files_uploader_client_request_key")
|
||||
@@index([caseId, status, createdAt], map: "files_case_status_created_at_idx")
|
||||
@@ -799,6 +882,319 @@ model File {
|
||||
@@map("files")
|
||||
}
|
||||
|
||||
model OldItemDropOffPoint {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
name String @db.VarChar(120)
|
||||
regionText String @map("region_text") @db.VarChar(120)
|
||||
addressText String @map("address_text") @db.VarChar(300)
|
||||
openingHours String @map("opening_hours") @db.VarChar(200)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
creator User @relation("OldItemDropOffPointCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
donations OldItemDonation[]
|
||||
|
||||
@@index([isActive, sortOrder], map: "old_item_drop_off_points_active_sort_idx")
|
||||
@@map("old_item_drop_off_points")
|
||||
}
|
||||
|
||||
model OldItemNeed {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
clientRequestId String @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
title String @db.VarChar(160)
|
||||
category OldItemCategory
|
||||
regionText String @map("region_text") @db.VarChar(120)
|
||||
beneficiaryGroup String @map("beneficiary_group") @db.VarChar(160)
|
||||
description String @db.Text
|
||||
requestedQuantity Int @map("requested_quantity") @db.UnsignedInt
|
||||
fulfilledQuantity Int @default(0) @map("fulfilled_quantity") @db.UnsignedInt
|
||||
priority Int @default(0)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
status OldItemNeedStatus @default(DRAFT)
|
||||
revision Int @default(1) @db.UnsignedInt
|
||||
publishedAt DateTime? @map("published_at") @db.DateTime(3)
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
creator User @relation("OldItemNeedCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
updater User @relation("OldItemNeedUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
|
||||
donations OldItemDonation[]
|
||||
history OldItemNeedStatusHistory[]
|
||||
inventoryItems OldItemInventoryItem[]
|
||||
|
||||
@@unique([createdById, clientRequestId], map: "old_item_needs_creator_request_key")
|
||||
@@index([status, category, priority, sortOrder], map: "old_item_needs_public_list_idx")
|
||||
@@map("old_item_needs")
|
||||
}
|
||||
|
||||
model OldItemNeedStatusHistory {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
needId BigInt @map("need_id") @db.UnsignedBigInt
|
||||
requestId String @unique @map("request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
fromStatus OldItemNeedStatus? @map("from_status")
|
||||
toStatus OldItemNeedStatus @map("to_status")
|
||||
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
need OldItemNeed @relation(fields: [needId], references: [id], onDelete: Restrict)
|
||||
operator User @relation("OldItemNeedStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([needId, createdAt], map: "old_item_need_history_need_created_idx")
|
||||
@@map("old_item_need_status_history")
|
||||
}
|
||||
|
||||
model OldItemDonation {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
donationNo String @unique @map("donation_no") @db.VarChar(32)
|
||||
donorUserId BigInt @map("donor_user_id") @db.UnsignedBigInt
|
||||
clientRequestId String @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
category OldItemCategory
|
||||
itemCondition OldItemCondition @map("item_condition")
|
||||
quantity Int @db.UnsignedInt
|
||||
descriptionEncrypted String @map("description_encrypted") @db.Text
|
||||
method OldItemDonationMethod
|
||||
contactNameEncrypted String @map("contact_name_encrypted") @db.VarChar(512)
|
||||
mobileEncrypted String @map("mobile_encrypted") @db.VarChar(512)
|
||||
mobileHash String @map("mobile_hash") @db.Char(64)
|
||||
addressEncrypted String? @map("address_encrypted") @db.Text
|
||||
appointmentAt DateTime? @map("appointment_at") @db.DateTime(3)
|
||||
dropOffPointId BigInt? @map("drop_off_point_id") @db.UnsignedBigInt
|
||||
targetNeedId BigInt? @map("target_need_id") @db.UnsignedBigInt
|
||||
assignedUserId BigInt? @map("assigned_user_id") @db.UnsignedBigInt
|
||||
rejectionReasonEncrypted String? @map("rejection_reason_encrypted") @db.Text
|
||||
status OldItemDonationStatus @default(SUBMITTED)
|
||||
revision Int @default(1) @db.UnsignedInt
|
||||
assistedPeople Int @default(0) @map("assisted_people") @db.UnsignedInt
|
||||
receivedAt DateTime? @map("received_at") @db.DateTime(3)
|
||||
completedAt DateTime? @map("completed_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
donor User @relation("OldItemDonor", fields: [donorUserId], references: [id], onDelete: Restrict)
|
||||
assignee User? @relation("OldItemAssignee", fields: [assignedUserId], references: [id], onDelete: Restrict)
|
||||
dropOffPoint OldItemDropOffPoint? @relation(fields: [dropOffPointId], references: [id], onDelete: Restrict)
|
||||
targetNeed OldItemNeed? @relation(fields: [targetNeedId], references: [id], onDelete: Restrict)
|
||||
history OldItemDonationStatusHistory[]
|
||||
files OldItemDonationFile[]
|
||||
inventoryItems OldItemInventoryItem[]
|
||||
certificates OldItemCertificate[]
|
||||
pointEntries OldItemPointEntry[]
|
||||
|
||||
@@unique([donorUserId, clientRequestId], map: "old_item_donations_donor_request_key")
|
||||
@@index([donorUserId, status, createdAt], map: "old_item_donations_donor_status_created_idx")
|
||||
@@index([status, method, createdAt], map: "old_item_donations_admin_list_idx")
|
||||
@@index([targetNeedId, status], map: "old_item_donations_need_status_idx")
|
||||
@@map("old_item_donations")
|
||||
}
|
||||
|
||||
model OldItemRewardConfig {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
clientRequestId String @unique @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
version Int @unique @db.UnsignedInt
|
||||
activeKey String? @unique @map("active_key") @db.VarChar(16)
|
||||
certificateTitle String @map("certificate_title") @db.VarChar(120)
|
||||
certificateMessage String @map("certificate_message") @db.VarChar(1000)
|
||||
issuerName String @map("issuer_name") @db.VarChar(120)
|
||||
legalNotice String @map("legal_notice") @db.VarChar(500)
|
||||
clothingPoints Int @map("clothing_points") @db.UnsignedInt
|
||||
booksPoints Int @map("books_points") @db.UnsignedInt
|
||||
householdPoints Int @map("household_points") @db.UnsignedInt
|
||||
sportsPoints Int @map("sports_points") @db.UnsignedInt
|
||||
maxPointsPerDonation Int @map("max_points_per_donation") @db.UnsignedInt
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
creator User @relation("OldItemRewardConfigCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
certificates OldItemCertificate[]
|
||||
pointEntries OldItemPointEntry[]
|
||||
|
||||
@@map("old_item_reward_configs")
|
||||
}
|
||||
|
||||
model OldItemCertificate {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
certificateNo String @unique @map("certificate_no") @db.VarChar(40)
|
||||
donationId BigInt @map("donation_id") @db.UnsignedBigInt
|
||||
configId BigInt? @map("config_id") @db.UnsignedBigInt
|
||||
issueRequestId String @unique @map("issue_request_id") @db.Char(36)
|
||||
issueFingerprint String @map("issue_fingerprint") @db.Char(64)
|
||||
activeKey String? @map("active_key") @db.VarChar(16)
|
||||
status OldItemCertificateStatus @default(ISSUED)
|
||||
recipientNameEncrypted String @map("recipient_name_encrypted") @db.VarChar(512)
|
||||
titleSnapshot String @map("title_snapshot") @db.VarChar(120)
|
||||
messageSnapshot String @map("message_snapshot") @db.VarChar(1000)
|
||||
issuerSnapshot String @map("issuer_snapshot") @db.VarChar(120)
|
||||
legalNoticeSnapshot String @map("legal_notice_snapshot") @db.VarChar(500)
|
||||
issuedById BigInt @map("issued_by") @db.UnsignedBigInt
|
||||
issuedAt DateTime @map("issued_at") @db.DateTime(3)
|
||||
revokedById BigInt? @map("revoked_by") @db.UnsignedBigInt
|
||||
revokedRequestId String? @unique @map("revoked_request_id") @db.Char(36)
|
||||
revokedFingerprint String? @map("revoked_fingerprint") @db.Char(64)
|
||||
revokeReasonEncrypted String? @map("revoke_reason_encrypted") @db.Text
|
||||
revokedAt DateTime? @map("revoked_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
donation OldItemDonation @relation(fields: [donationId], references: [id], onDelete: Restrict)
|
||||
config OldItemRewardConfig? @relation(fields: [configId], references: [id], onDelete: Restrict)
|
||||
issuer User @relation("OldItemCertificateIssuer", fields: [issuedById], references: [id], onDelete: Restrict)
|
||||
revoker User? @relation("OldItemCertificateRevoker", fields: [revokedById], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([donationId, activeKey], map: "old_item_certificates_donation_active_key")
|
||||
@@index([donationId, issuedAt], map: "old_item_certificates_donation_issued_idx")
|
||||
@@map("old_item_certificates")
|
||||
}
|
||||
|
||||
model OldItemPointAccount {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
userId BigInt @unique @map("user_id") @db.UnsignedBigInt
|
||||
balance Int @default(0) @db.UnsignedInt
|
||||
revision Int @default(1) @db.UnsignedInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
entries OldItemPointEntry[]
|
||||
|
||||
@@map("old_item_point_accounts")
|
||||
}
|
||||
|
||||
model OldItemPointEntry {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
accountId BigInt @map("account_id") @db.UnsignedBigInt
|
||||
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||
donationId BigInt? @map("donation_id") @db.UnsignedBigInt
|
||||
configId BigInt? @map("config_id") @db.UnsignedBigInt
|
||||
requestId String @unique @map("request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
sourceKey String @unique @map("source_key") @db.VarChar(100)
|
||||
entryType OldItemPointEntryType @map("entry_type")
|
||||
pointsDelta Int @map("points_delta")
|
||||
balanceAfter Int @map("balance_after") @db.UnsignedInt
|
||||
noteEncrypted String? @map("note_encrypted") @db.Text
|
||||
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
account OldItemPointAccount @relation(fields: [accountId], references: [id], onDelete: Restrict)
|
||||
user User @relation("OldItemPointOwner", fields: [userId], references: [id], onDelete: Restrict)
|
||||
donation OldItemDonation? @relation(fields: [donationId], references: [id], onDelete: Restrict)
|
||||
config OldItemRewardConfig? @relation(fields: [configId], references: [id], onDelete: Restrict)
|
||||
operator User @relation("OldItemPointOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([userId, createdAt], map: "old_item_point_entries_user_created_idx")
|
||||
@@index([donationId], map: "old_item_point_entries_donation_idx")
|
||||
@@map("old_item_point_entries")
|
||||
}
|
||||
|
||||
model OldItemInventoryImport {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
clientRequestId String @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
originalFilenameEncrypted String @map("original_filename_encrypted") @db.VarChar(512)
|
||||
rowCount Int @map("row_count") @db.UnsignedInt
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
creator User @relation("OldItemInventoryImportCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
items OldItemInventoryItem[]
|
||||
|
||||
@@unique([createdById, clientRequestId], map: "old_item_inventory_import_creator_request_key")
|
||||
@@map("old_item_inventory_imports")
|
||||
}
|
||||
|
||||
model OldItemInventoryItem {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
inventoryNo String @unique @map("inventory_no") @db.VarChar(32)
|
||||
sourceDonationId BigInt? @unique @map("source_donation_id") @db.UnsignedBigInt
|
||||
importId BigInt? @map("import_id") @db.UnsignedBigInt
|
||||
category OldItemCategory
|
||||
itemName String @map("item_name") @db.VarChar(160)
|
||||
quantity Int @db.UnsignedInt
|
||||
unit String @default("件") @db.VarChar(20)
|
||||
storageLocation String @map("storage_location") @db.VarChar(160)
|
||||
status OldItemInventoryStatus @default(IN_STOCK)
|
||||
targetNeedId BigInt? @map("target_need_id") @db.UnsignedBigInt
|
||||
beneficiaryId BigInt? @map("beneficiary_id") @db.UnsignedBigInt
|
||||
destinationRegion String? @map("destination_region") @db.VarChar(120)
|
||||
noteEncrypted String? @map("note_encrypted") @db.Text
|
||||
landingAt DateTime? @map("landing_at") @db.DateTime(3)
|
||||
revision Int @default(1) @db.UnsignedInt
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
sourceDonation OldItemDonation? @relation(fields: [sourceDonationId], references: [id], onDelete: Restrict)
|
||||
import OldItemInventoryImport? @relation(fields: [importId], references: [id], onDelete: Restrict)
|
||||
targetNeed OldItemNeed? @relation(fields: [targetNeedId], references: [id], onDelete: Restrict)
|
||||
beneficiary BeneficiaryProfile? @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict)
|
||||
creator User @relation("OldItemInventoryCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
updater User @relation("OldItemInventoryUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
|
||||
events OldItemInventoryEvent[]
|
||||
|
||||
@@index([status, category, createdAt], map: "old_item_inventory_status_category_created_idx")
|
||||
@@index([targetNeedId, status], map: "old_item_inventory_need_status_idx")
|
||||
@@index([beneficiaryId, status], map: "old_item_inventory_beneficiary_status_idx")
|
||||
@@map("old_item_inventory_items")
|
||||
}
|
||||
|
||||
model OldItemInventoryEvent {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
inventoryItemId BigInt @map("inventory_item_id") @db.UnsignedBigInt
|
||||
requestId String @unique @map("request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
eventType OldItemInventoryEventType @map("event_type")
|
||||
fromStatus OldItemInventoryStatus? @map("from_status")
|
||||
toStatus OldItemInventoryStatus @map("to_status")
|
||||
locationText String? @map("location_text") @db.VarChar(160)
|
||||
noteEncrypted String? @map("note_encrypted") @db.Text
|
||||
eventAt DateTime @map("event_at") @db.DateTime(3)
|
||||
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
inventoryItem OldItemInventoryItem @relation(fields: [inventoryItemId], references: [id], onDelete: Restrict)
|
||||
operator User @relation("OldItemInventoryEventOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([inventoryItemId, eventAt], map: "old_item_inventory_events_item_event_idx")
|
||||
@@map("old_item_inventory_events")
|
||||
}
|
||||
|
||||
model OldItemDonationStatusHistory {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
donationId BigInt @map("donation_id") @db.UnsignedBigInt
|
||||
requestId String @unique @map("request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
fromStatus OldItemDonationStatus? @map("from_status")
|
||||
toStatus OldItemDonationStatus @map("to_status")
|
||||
noteEncrypted String? @map("note_encrypted") @db.Text
|
||||
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
donation OldItemDonation @relation(fields: [donationId], references: [id], onDelete: Restrict)
|
||||
operator User @relation("OldItemDonationStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([donationId, createdAt], map: "old_item_donation_history_donation_created_idx")
|
||||
@@map("old_item_donation_status_history")
|
||||
}
|
||||
|
||||
model OldItemDonationFile {
|
||||
donationId BigInt @map("donation_id") @db.UnsignedBigInt
|
||||
fileId BigInt @map("file_id") @db.UnsignedBigInt
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
attachedAt DateTime @default(now()) @map("attached_at") @db.DateTime(3)
|
||||
donation OldItemDonation @relation(fields: [donationId], references: [id], onDelete: Restrict)
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@id([donationId, fileId])
|
||||
@@index([fileId], map: "old_item_donation_files_file_idx")
|
||||
@@map("old_item_donation_files")
|
||||
}
|
||||
|
||||
model ServiceRecordFile {
|
||||
serviceRecordId BigInt @map("service_record_id") @db.UnsignedBigInt
|
||||
fileId BigInt @map("file_id") @db.UnsignedBigInt
|
||||
@@ -1158,24 +1554,24 @@ model ContentPostStatusHistory {
|
||||
}
|
||||
|
||||
model FinancialDisclosure {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
clientRequestId String @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
title String @db.VarChar(200)
|
||||
summary String? @db.VarChar(500)
|
||||
richText String @map("rich_text") @db.MediumText
|
||||
status FinancialDisclosureStatus @default(DRAFT)
|
||||
isPinned Boolean @default(false) @map("is_pinned")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
revision Int @default(1) @db.UnsignedInt
|
||||
publishedAt DateTime? @map("published_at") @db.DateTime(3)
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
creator User @relation("FinancialDisclosureCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
updater User @relation("FinancialDisclosureUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
clientRequestId String @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
title String @db.VarChar(200)
|
||||
summary String? @db.VarChar(500)
|
||||
richText String @map("rich_text") @db.MediumText
|
||||
status FinancialDisclosureStatus @default(DRAFT)
|
||||
isPinned Boolean @default(false) @map("is_pinned")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
revision Int @default(1) @db.UnsignedInt
|
||||
publishedAt DateTime? @map("published_at") @db.DateTime(3)
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
creator User @relation("FinancialDisclosureCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
updater User @relation("FinancialDisclosureUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
|
||||
statusHistory FinancialDisclosureStatusHistory[]
|
||||
|
||||
@@unique([createdById, clientRequestId], map: "financial_disclosures_creator_request_key")
|
||||
@@ -1185,16 +1581,16 @@ model FinancialDisclosure {
|
||||
}
|
||||
|
||||
model FinancialDisclosureStatusHistory {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
financialDisclosureId BigInt @map("financial_disclosure_id") @db.UnsignedBigInt
|
||||
requestId String @unique @map("request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
fromStatus FinancialDisclosureStatus? @map("from_status")
|
||||
toStatus FinancialDisclosureStatus @map("to_status")
|
||||
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
financialDisclosure FinancialDisclosure @relation(fields: [financialDisclosureId], references: [id], onDelete: Restrict)
|
||||
operator User @relation("FinancialDisclosureStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
financialDisclosureId BigInt @map("financial_disclosure_id") @db.UnsignedBigInt
|
||||
requestId String @unique @map("request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
fromStatus FinancialDisclosureStatus? @map("from_status")
|
||||
toStatus FinancialDisclosureStatus @map("to_status")
|
||||
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
financialDisclosure FinancialDisclosure @relation(fields: [financialDisclosureId], references: [id], onDelete: Restrict)
|
||||
operator User @relation("FinancialDisclosureStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([financialDisclosureId, createdAt], map: "financial_disclosure_history_document_idx")
|
||||
@@index([operatorUserId, createdAt], map: "financial_disclosure_history_operator_idx")
|
||||
|
||||
@@ -26,6 +26,7 @@ import { RequestMetricsService } from './common/request-metrics.service';
|
||||
import { RequestMetricsInterceptor } from './common/request-metrics.interceptor';
|
||||
import { SystemMetricsController } from './common/system-metrics.controller';
|
||||
import { FinancialDisclosuresModule } from './financial-disclosures/financial-disclosures.module';
|
||||
import { OldItemsModule } from './old-items/old-items.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -44,6 +45,7 @@ import { FinancialDisclosuresModule } from './financial-disclosures/financial-di
|
||||
ContentNotificationsModule,
|
||||
StatisticsModule,
|
||||
FinancialDisclosuresModule,
|
||||
OldItemsModule,
|
||||
],
|
||||
controllers: [HealthController, SystemMetricsController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsMobilePhone,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Length,
|
||||
Max,
|
||||
Matches,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
OLD_ITEM_CATEGORIES,
|
||||
OLD_ITEM_CONDITIONS,
|
||||
OLD_ITEM_DONATION_METHODS,
|
||||
OLD_ITEM_DONATION_STATUSES,
|
||||
OLD_ITEM_NEED_STATUSES,
|
||||
OLD_ITEM_INVENTORY_STATUSES,
|
||||
} from '@lehuo/contracts';
|
||||
import { PaginationDto } from '../../case-management/dto/case-management.dto';
|
||||
|
||||
export class OldItemDonationQueryDto extends PaginationDto {
|
||||
@IsOptional() @IsIn(OLD_ITEM_DONATION_STATUSES) status?: string;
|
||||
@IsOptional() @IsIn(OLD_ITEM_DONATION_METHODS) method?: string;
|
||||
@IsOptional() @IsString() @Length(1, 100) keyword?: string;
|
||||
}
|
||||
export class OldItemNeedQueryDto extends PaginationDto {
|
||||
@IsOptional() @IsIn(OLD_ITEM_CATEGORIES) category?: string;
|
||||
@IsOptional() @IsIn(OLD_ITEM_NEED_STATUSES) status?: string;
|
||||
}
|
||||
export class CreateOldItemDonationDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
|
||||
@IsIn(OLD_ITEM_CONDITIONS) itemCondition!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(9999) quantity!: number;
|
||||
@IsString() @Length(2, 1000) description!: string;
|
||||
@IsIn(OLD_ITEM_DONATION_METHODS) method!: 'PICKUP' | 'DROP_OFF';
|
||||
@IsString() @Length(2, 50) contactName!: string;
|
||||
@IsMobilePhone('zh-CN') mobile!: string;
|
||||
@IsOptional() @IsString() @Length(5, 500) address?: string;
|
||||
@IsOptional() @IsDateString() appointmentAt?: string;
|
||||
@IsOptional() @IsUUID('4') dropOffPointPublicId?: string;
|
||||
@IsOptional() @IsUUID('4') targetNeedPublicId?: string;
|
||||
@IsOptional() @IsArray() @IsUUID('4', { each: true }) imagePublicIds?: string[];
|
||||
}
|
||||
export class CreateOldItemMediaUploadIntentDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsIn(['OLD_ITEM_DONATION_IMAGE']) businessType!: 'OLD_ITEM_DONATION_IMAGE';
|
||||
@IsString() @Length(1, 255) filename!: string;
|
||||
@IsIn(['image/jpeg', 'image/png', 'image/webp']) mimeType!:
|
||||
'image/jpeg' | 'image/png' | 'image/webp';
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(10 * 1024 * 1024) size!: number;
|
||||
@IsOptional() @Matches(/^[a-fA-F0-9]{64}$/) sha256?: string;
|
||||
}
|
||||
export class TransitionOldItemDonationDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
|
||||
@IsIn(OLD_ITEM_DONATION_STATUSES) targetStatus!: string;
|
||||
@IsOptional() @IsString() @Length(1, 500) note?: string;
|
||||
@IsOptional() @IsUUID('4') assignedUserPublicId?: string;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(999999) assistedPeople?: number;
|
||||
}
|
||||
export class CreateOldItemNeedDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsString() @Length(2, 160) title!: string;
|
||||
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
|
||||
@IsString() @Length(2, 120) regionText!: string;
|
||||
@IsString() @Length(2, 160) beneficiaryGroup!: string;
|
||||
@IsString() @Length(2, 2000) description!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(999999) requestedQuantity!: number;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100) priority?: number;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
|
||||
}
|
||||
export class UpdateOldItemNeedDto {
|
||||
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
|
||||
@IsString() @Length(2, 160) title!: string;
|
||||
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
|
||||
@IsString() @Length(2, 120) regionText!: string;
|
||||
@IsString() @Length(2, 160) beneficiaryGroup!: string;
|
||||
@IsString() @Length(2, 2000) description!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(999999) requestedQuantity!: number;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100) priority?: number;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
|
||||
}
|
||||
export class TransitionOldItemNeedDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
|
||||
@IsIn(OLD_ITEM_NEED_STATUSES) targetStatus!: string;
|
||||
}
|
||||
export class CreateOldItemDropOffPointDto {
|
||||
@IsString() @Length(2, 120) name!: string;
|
||||
@IsString() @Length(2, 120) regionText!: string;
|
||||
@IsString() @Length(5, 300) addressText!: string;
|
||||
@IsString() @Length(2, 200) openingHours!: string;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
|
||||
}
|
||||
|
||||
export class OldItemInventoryQueryDto extends PaginationDto {
|
||||
@IsOptional() @IsIn(OLD_ITEM_INVENTORY_STATUSES) status?: string;
|
||||
@IsOptional() @IsIn(OLD_ITEM_CATEGORIES) category?: string;
|
||||
@IsOptional() @IsString() @Length(1, 100) keyword?: string;
|
||||
}
|
||||
|
||||
export class CreateOldItemInventoryDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsOptional() @IsUUID('4') sourceDonationPublicId?: string;
|
||||
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
|
||||
@IsString() @Length(1, 160) itemName!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(999999) quantity!: number;
|
||||
@IsString() @Length(1, 20) unit!: string;
|
||||
@IsString() @Length(2, 160) storageLocation!: string;
|
||||
@IsOptional() @IsUUID('4') targetNeedPublicId?: string;
|
||||
@IsOptional() @IsString() @Length(1, 1000) note?: string;
|
||||
@IsOptional() @IsDateString() inboundAt?: string;
|
||||
}
|
||||
|
||||
export class OldItemInventoryImportRowDto {
|
||||
@IsOptional() @IsString() @Length(1, 32) sourceDonationNo?: string;
|
||||
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
|
||||
@IsString() @Length(1, 160) itemName!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(999999) quantity!: number;
|
||||
@IsString() @Length(1, 20) unit!: string;
|
||||
@IsString() @Length(2, 160) storageLocation!: string;
|
||||
@IsOptional() @IsUUID('4') targetNeedPublicId?: string;
|
||||
@IsOptional() @IsString() @Length(1, 1000) note?: string;
|
||||
@IsOptional() @IsDateString() inboundAt?: string;
|
||||
}
|
||||
|
||||
export class ImportOldItemInventoryDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsString() @Length(1, 255) originalFilename!: string;
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(500)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OldItemInventoryImportRowDto)
|
||||
rows!: OldItemInventoryImportRowDto[];
|
||||
}
|
||||
|
||||
export class TransitionOldItemInventoryDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
|
||||
@IsIn(OLD_ITEM_INVENTORY_STATUSES) targetStatus!: string;
|
||||
@IsOptional() @IsDateString() eventAt?: string;
|
||||
@IsOptional() @IsString() @Length(2, 160) locationText?: string;
|
||||
@IsOptional() @IsString() @Length(1, 1000) note?: string;
|
||||
@IsOptional() @IsUUID('4') targetNeedPublicId?: string;
|
||||
@IsOptional() @IsUUID('4') beneficiaryPublicId?: string;
|
||||
@IsOptional() @IsString() @Length(2, 120) destinationRegion?: string;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(999999) assistedPeople?: number;
|
||||
}
|
||||
|
||||
export class ActivateOldItemRewardConfigDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsString() @Length(2, 120) certificateTitle!: string;
|
||||
@IsString() @Length(10, 1000) certificateMessage!: string;
|
||||
@IsString() @Length(2, 120) issuerName!: string;
|
||||
@IsString() @Length(10, 500) legalNotice!: string;
|
||||
@Type(() => Number) @IsInt() @Min(0) @Max(1000) clothingPoints!: number;
|
||||
@Type(() => Number) @IsInt() @Min(0) @Max(1000) booksPoints!: number;
|
||||
@Type(() => Number) @IsInt() @Min(0) @Max(1000) householdPoints!: number;
|
||||
@Type(() => Number) @IsInt() @Min(0) @Max(1000) sportsPoints!: number;
|
||||
@Type(() => Number) @IsInt() @Min(0) @Max(100000) maxPointsPerDonation!: number;
|
||||
}
|
||||
|
||||
export class IssueOldItemCertificateDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
}
|
||||
|
||||
export class RevokeOldItemCertificateDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsString() @Length(2, 500) reason!: string;
|
||||
}
|
||||
|
||||
export class AdjustOldItemPointsDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
|
||||
@Type(() => Number) @IsInt() @Min(-100000) @Max(100000) pointsDelta!: number;
|
||||
@IsString() @Length(2, 500) reason!: string;
|
||||
}
|
||||
|
||||
export class OldItemPointAccountQueryDto extends PaginationDto {
|
||||
@IsOptional() @IsString() @Length(1, 100) keyword?: string;
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { API_ERROR_CODES, PERMISSION_CODES, type OldItemInventoryItem } from '@lehuo/contracts';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { ApplicationError } from '../common/application-error';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import type { Prisma } from '../generated/prisma/client';
|
||||
import { SensitiveDataService } from '../security/sensitive-data.service';
|
||||
import type {
|
||||
CreateOldItemInventoryDto,
|
||||
ImportOldItemInventoryDto,
|
||||
OldItemInventoryImportRowDto,
|
||||
OldItemInventoryQueryDto,
|
||||
TransitionOldItemInventoryDto,
|
||||
} from './dto/old-items.dto';
|
||||
import { OldItemRewardsService } from './old-item-rewards.service';
|
||||
|
||||
const TRANSITIONS: Record<string, string[]> = {
|
||||
IN_STOCK: ['SORTED'],
|
||||
SORTED: ['DISINFECTED'],
|
||||
DISINFECTED: ['OUTBOUND'],
|
||||
OUTBOUND: ['DELIVERED'],
|
||||
DELIVERED: [],
|
||||
};
|
||||
const EVENT_BY_STATUS = {
|
||||
IN_STOCK: 'INBOUND',
|
||||
SORTED: 'SORTED',
|
||||
DISINFECTED: 'DISINFECTED',
|
||||
OUTBOUND: 'OUTBOUND',
|
||||
DELIVERED: 'DELIVERED',
|
||||
} as const;
|
||||
const INCLUDE = {
|
||||
sourceDonation: { select: { publicId: true, donationNo: true, status: true, revision: true } },
|
||||
targetNeed: { select: { publicId: true, title: true } },
|
||||
beneficiary: { select: { publicId: true } },
|
||||
events: {
|
||||
include: { operator: { include: { staffProfile: true } } },
|
||||
orderBy: { eventAt: 'asc' as const },
|
||||
},
|
||||
} satisfies Prisma.OldItemInventoryItemInclude;
|
||||
type InventoryWithRelations = Prisma.OldItemInventoryItemGetPayload<{ include: typeof INCLUDE }>;
|
||||
|
||||
@Injectable()
|
||||
export class OldItemInventoryService {
|
||||
constructor(
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
|
||||
@Inject(AuditService) private readonly audit: AuditService,
|
||||
@Inject(OldItemRewardsService) private readonly rewards: OldItemRewardsService,
|
||||
) {}
|
||||
|
||||
async list(q: OldItemInventoryQueryDto) {
|
||||
const where: Prisma.OldItemInventoryItemWhereInput = {
|
||||
...(q.status ? { status: q.status as never } : {}),
|
||||
...(q.category ? { category: q.category as never } : {}),
|
||||
...(q.keyword
|
||||
? {
|
||||
OR: [
|
||||
{ inventoryNo: { contains: q.keyword } },
|
||||
{ itemName: { contains: q.keyword } },
|
||||
{ sourceDonation: { donationNo: { contains: q.keyword } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.oldItemInventoryItem.count({ where }),
|
||||
this.prisma.oldItemInventoryItem.findMany({
|
||||
where,
|
||||
include: INCLUDE,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (q.page - 1) * q.pageSize,
|
||||
take: q.pageSize,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
items: items.map((item) => this.map(item)),
|
||||
page: q.page,
|
||||
pageSize: q.pageSize,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async get(publicId: string, actor: AuthContext, req: RequestContext) {
|
||||
const item = await this.find(publicId);
|
||||
await this.audit.record(req, {
|
||||
action: 'OLD_ITEM_INVENTORY_VIEWED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_INVENTORY',
|
||||
resourcePublicId: publicId,
|
||||
});
|
||||
return { item: this.map(item) };
|
||||
}
|
||||
|
||||
async create(input: CreateOldItemInventoryDto, actor: AuthContext, req: RequestContext) {
|
||||
const fp = this.fingerprint('old-item-inventory-create', input);
|
||||
const replay = await this.prisma.oldItemInventoryEvent.findUnique({
|
||||
where: { requestId: input.clientRequestId },
|
||||
include: { inventoryItem: { include: INCLUDE } },
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { item: this.map(replay.inventoryItem), idempotentReplay: true };
|
||||
}
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const links = await this.resolveLinks(
|
||||
tx,
|
||||
input.sourceDonationPublicId,
|
||||
undefined,
|
||||
input.targetNeedPublicId,
|
||||
);
|
||||
const eventAt = this.eventAt(input.inboundAt);
|
||||
const item = await tx.oldItemInventoryItem.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
inventoryNo: this.inventoryNo(),
|
||||
sourceDonationId: links.donation?.id,
|
||||
category: input.category as never,
|
||||
itemName: input.itemName.trim(),
|
||||
quantity: input.quantity,
|
||||
unit: input.unit.trim(),
|
||||
storageLocation: input.storageLocation.trim(),
|
||||
targetNeedId: links.need?.id,
|
||||
noteEncrypted: input.note ? this.sensitive.encrypt(input.note.trim()) : null,
|
||||
createdById: actor.userId,
|
||||
updatedById: actor.userId,
|
||||
},
|
||||
});
|
||||
await tx.oldItemInventoryEvent.create({
|
||||
data: {
|
||||
inventoryItemId: item.id,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
eventType: 'INBOUND',
|
||||
toStatus: 'IN_STOCK',
|
||||
locationText: input.storageLocation.trim(),
|
||||
noteEncrypted: input.note ? this.sensitive.encrypt(input.note.trim()) : null,
|
||||
eventAt,
|
||||
operatorUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_INVENTORY_CREATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_INVENTORY',
|
||||
resourcePublicId: item.publicId,
|
||||
metadata: { category: input.category, quantity: input.quantity, source: 'MANUAL' },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return tx.oldItemInventoryItem.findUniqueOrThrow({
|
||||
where: { id: item.id },
|
||||
include: INCLUDE,
|
||||
});
|
||||
});
|
||||
return { item: this.map(created), idempotentReplay: false };
|
||||
}
|
||||
|
||||
async import(input: ImportOldItemInventoryDto, actor: AuthContext, req: RequestContext) {
|
||||
const fp = this.fingerprint('old-item-inventory-import', input);
|
||||
const replay = await this.prisma.oldItemInventoryImport.findUnique({
|
||||
where: {
|
||||
createdById_clientRequestId: {
|
||||
createdById: actor.userId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
},
|
||||
},
|
||||
include: { items: { include: INCLUDE, orderBy: { createdAt: 'asc' } } },
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return {
|
||||
importPublicId: replay.publicId,
|
||||
items: replay.items.map((x) => this.map(x)),
|
||||
idempotentReplay: true,
|
||||
};
|
||||
}
|
||||
this.validateImportDuplicates(input.rows);
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const batch = await tx.oldItemInventoryImport.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
clientRequestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
originalFilenameEncrypted: this.sensitive.encrypt(input.originalFilename.trim()),
|
||||
rowCount: input.rows.length,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
const items: InventoryWithRelations[] = [];
|
||||
for (const [index, row] of input.rows.entries()) {
|
||||
const links = await this.resolveLinks(
|
||||
tx,
|
||||
undefined,
|
||||
row.sourceDonationNo,
|
||||
row.targetNeedPublicId,
|
||||
);
|
||||
const item = await this.createImportRow(
|
||||
tx,
|
||||
batch.id,
|
||||
row,
|
||||
index,
|
||||
links,
|
||||
actor.userId,
|
||||
input.clientRequestId,
|
||||
fp,
|
||||
);
|
||||
items.push(item);
|
||||
}
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_INVENTORY_IMPORTED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_INVENTORY_IMPORT',
|
||||
resourcePublicId: batch.publicId,
|
||||
metadata: { rowCount: input.rows.length },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return { batch, items };
|
||||
});
|
||||
return {
|
||||
importPublicId: created.batch.publicId,
|
||||
items: created.items.map((x) => this.map(x)),
|
||||
idempotentReplay: false,
|
||||
};
|
||||
}
|
||||
|
||||
async transition(
|
||||
publicId: string,
|
||||
input: TransitionOldItemInventoryDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const fp = this.fingerprint('old-item-inventory-transition', { publicId, ...input });
|
||||
const replay = await this.prisma.oldItemInventoryEvent.findUnique({
|
||||
where: { requestId: input.clientRequestId },
|
||||
include: { inventoryItem: { include: INCLUDE } },
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { item: this.map(replay.inventoryItem), idempotentReplay: true };
|
||||
}
|
||||
const current = await this.find(publicId);
|
||||
if (current.revision !== input.expectedRevision) throw this.conflict();
|
||||
if (!TRANSITIONS[current.status]?.includes(input.targetStatus)) throw this.invalid();
|
||||
if (
|
||||
input.targetStatus === 'OUTBOUND' &&
|
||||
!input.destinationRegion &&
|
||||
!input.targetNeedPublicId &&
|
||||
!input.beneficiaryPublicId
|
||||
)
|
||||
throw this.validation('出库时必须绑定物资需求、受助对象或帮扶区域之一');
|
||||
if (input.targetStatus === 'DELIVERED' && input.assistedPeople === undefined)
|
||||
throw this.validation('帮扶落地时必须填写实际帮扶人数');
|
||||
const eventAt = this.eventAt(input.eventAt);
|
||||
const previousAt = current.events.at(-1)?.eventAt;
|
||||
if (previousAt && eventAt < previousAt) throw this.validation('流转时间不能早于上一节点');
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const links = await this.resolveDestination(tx, current, input, actor);
|
||||
const changed = await tx.oldItemInventoryItem.updateMany({
|
||||
where: { id: current.id, revision: input.expectedRevision, status: current.status },
|
||||
data: {
|
||||
status: input.targetStatus as never,
|
||||
revision: { increment: 1 },
|
||||
updatedById: actor.userId,
|
||||
...(input.locationText ? { storageLocation: input.locationText.trim() } : {}),
|
||||
...(input.targetStatus === 'OUTBOUND'
|
||||
? {
|
||||
targetNeedId: links.needId,
|
||||
beneficiaryId: links.beneficiaryId,
|
||||
destinationRegion: input.destinationRegion?.trim() ?? current.destinationRegion,
|
||||
}
|
||||
: {}),
|
||||
...(input.targetStatus === 'DELIVERED' ? { landingAt: eventAt } : {}),
|
||||
...(input.note ? { noteEncrypted: this.sensitive.encrypt(input.note.trim()) } : {}),
|
||||
},
|
||||
});
|
||||
if (changed.count !== 1) throw this.conflict();
|
||||
await tx.oldItemInventoryEvent.create({
|
||||
data: {
|
||||
inventoryItemId: current.id,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
eventType: EVENT_BY_STATUS[input.targetStatus as keyof typeof EVENT_BY_STATUS],
|
||||
fromStatus: current.status,
|
||||
toStatus: input.targetStatus as never,
|
||||
locationText:
|
||||
input.locationText?.trim() ??
|
||||
(input.targetStatus === 'OUTBOUND' ? input.destinationRegion?.trim() : null),
|
||||
noteEncrypted: input.note ? this.sensitive.encrypt(input.note.trim()) : null,
|
||||
eventAt,
|
||||
operatorUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.syncDonation(tx, current, input, actor, req, fp, eventAt);
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_INVENTORY_STATUS_CHANGED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_INVENTORY',
|
||||
resourcePublicId: publicId,
|
||||
metadata: { fromStatus: current.status, toStatus: input.targetStatus },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return tx.oldItemInventoryItem.findUniqueOrThrow({
|
||||
where: { id: current.id },
|
||||
include: INCLUDE,
|
||||
});
|
||||
});
|
||||
return { item: this.map(updated), idempotentReplay: false };
|
||||
}
|
||||
|
||||
async export(q: OldItemInventoryQueryDto, actor: AuthContext, req: RequestContext) {
|
||||
const result = await this.list({
|
||||
...q,
|
||||
page: 1,
|
||||
pageSize: Math.min(5000, Math.max(q.pageSize, 5000)),
|
||||
});
|
||||
await this.audit.record(req, {
|
||||
action: 'OLD_ITEM_INVENTORY_EXPORTED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_INVENTORY_EXPORT',
|
||||
metadata: {
|
||||
rowCount: result.items.length,
|
||||
status: q.status ?? null,
|
||||
category: q.category ?? null,
|
||||
},
|
||||
});
|
||||
return {
|
||||
rows: result.items.map((x) => ({
|
||||
inventoryNo: x.inventoryNo,
|
||||
sourceDonationNo: x.sourceDonation?.donationNo ?? '',
|
||||
category: x.category,
|
||||
itemName: x.itemName,
|
||||
quantity: x.quantity,
|
||||
unit: x.unit,
|
||||
storageLocation: x.storageLocation,
|
||||
status: x.status,
|
||||
targetNeedTitle: x.targetNeed?.title ?? '',
|
||||
beneficiaryPublicId: x.beneficiaryPublicId ?? '',
|
||||
destinationRegion: x.destinationRegion ?? '',
|
||||
landingAt: x.landingAt ?? '',
|
||||
createdAt: x.createdAt,
|
||||
})),
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async createImportRow(
|
||||
tx: Prisma.TransactionClient,
|
||||
importId: bigint,
|
||||
row: OldItemInventoryImportRowDto,
|
||||
index: number,
|
||||
links: { donation: { id: bigint } | null; need: { id: bigint } | null },
|
||||
actorId: bigint,
|
||||
requestId: string,
|
||||
fp: string,
|
||||
) {
|
||||
const item = await tx.oldItemInventoryItem.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
inventoryNo: this.inventoryNo(),
|
||||
sourceDonationId: links.donation?.id,
|
||||
importId,
|
||||
category: row.category as never,
|
||||
itemName: row.itemName.trim(),
|
||||
quantity: row.quantity,
|
||||
unit: row.unit.trim(),
|
||||
storageLocation: row.storageLocation.trim(),
|
||||
targetNeedId: links.need?.id,
|
||||
noteEncrypted: row.note ? this.sensitive.encrypt(row.note.trim()) : null,
|
||||
createdById: actorId,
|
||||
updatedById: actorId,
|
||||
},
|
||||
});
|
||||
await tx.oldItemInventoryEvent.create({
|
||||
data: {
|
||||
inventoryItemId: item.id,
|
||||
requestId: this.derivedRequestId(requestId, index),
|
||||
requestFingerprint: fp,
|
||||
eventType: 'INBOUND',
|
||||
toStatus: 'IN_STOCK',
|
||||
locationText: row.storageLocation.trim(),
|
||||
noteEncrypted: row.note ? this.sensitive.encrypt(row.note.trim()) : null,
|
||||
eventAt: this.eventAt(row.inboundAt),
|
||||
operatorUserId: actorId,
|
||||
},
|
||||
});
|
||||
return tx.oldItemInventoryItem.findUniqueOrThrow({ where: { id: item.id }, include: INCLUDE });
|
||||
}
|
||||
|
||||
private async resolveLinks(
|
||||
tx: Prisma.TransactionClient,
|
||||
donationPublicId?: string,
|
||||
donationNo?: string,
|
||||
needPublicId?: string,
|
||||
) {
|
||||
const [donation, need] = await Promise.all([
|
||||
donationPublicId || donationNo
|
||||
? tx.oldItemDonation.findFirst({
|
||||
where: donationPublicId ? { publicId: donationPublicId } : { donationNo },
|
||||
})
|
||||
: null,
|
||||
needPublicId ? tx.oldItemNeed.findUnique({ where: { publicId: needPublicId } }) : null,
|
||||
]);
|
||||
if ((donationPublicId || donationNo) && !donation) throw this.validation('来源捐赠编号不存在');
|
||||
if (donation && !['RECEIVED', 'PROCESSING', 'COMPLETED'].includes(donation.status))
|
||||
throw this.validation('来源捐赠尚未接收,不能入库');
|
||||
if (donation) {
|
||||
const existing = await tx.oldItemInventoryItem.findUnique({
|
||||
where: { sourceDonationId: donation.id },
|
||||
select: { publicId: true },
|
||||
});
|
||||
if (existing) throw this.validation('来源捐赠已完成入库,不能重复绑定');
|
||||
}
|
||||
if (needPublicId && !need) throw this.validation('定向物资需求不存在');
|
||||
return { donation, need };
|
||||
}
|
||||
|
||||
private async resolveDestination(
|
||||
tx: Prisma.TransactionClient,
|
||||
current: InventoryWithRelations,
|
||||
input: TransitionOldItemInventoryDto,
|
||||
actor: AuthContext,
|
||||
) {
|
||||
const need = input.targetNeedPublicId
|
||||
? await tx.oldItemNeed.findUnique({ where: { publicId: input.targetNeedPublicId } })
|
||||
: null;
|
||||
if (
|
||||
input.beneficiaryPublicId &&
|
||||
!actor.permissions.includes(PERMISSION_CODES.BENEFICIARIES_READ) &&
|
||||
!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
|
||||
)
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.FORBIDDEN,
|
||||
'无权绑定受助对象',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
const beneficiary = input.beneficiaryPublicId
|
||||
? await tx.beneficiaryProfile.findFirst({
|
||||
where: { publicId: input.beneficiaryPublicId, status: 'ACTIVE' },
|
||||
})
|
||||
: null;
|
||||
if (input.targetNeedPublicId && !need) throw this.validation('物资需求不存在');
|
||||
if (input.beneficiaryPublicId && !beneficiary) throw this.validation('受助对象不存在或不可用');
|
||||
return {
|
||||
needId: need?.id ?? (input.targetNeedPublicId ? null : current.targetNeedId),
|
||||
beneficiaryId: beneficiary?.id ?? (input.beneficiaryPublicId ? null : current.beneficiaryId),
|
||||
};
|
||||
}
|
||||
|
||||
private async syncDonation(
|
||||
tx: Prisma.TransactionClient,
|
||||
current: InventoryWithRelations,
|
||||
input: TransitionOldItemInventoryDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
fp: string,
|
||||
eventAt: Date,
|
||||
) {
|
||||
const donation = current.sourceDonation;
|
||||
if (
|
||||
input.targetStatus === 'DELIVERED' &&
|
||||
current.targetNeedId &&
|
||||
(!donation || donation.status !== 'COMPLETED')
|
||||
) {
|
||||
const need = await tx.oldItemNeed.findUniqueOrThrow({
|
||||
where: { id: current.targetNeedId },
|
||||
});
|
||||
const fulfilled = Math.min(need.requestedQuantity, need.fulfilledQuantity + current.quantity);
|
||||
await tx.oldItemNeed.update({
|
||||
where: { id: need.id },
|
||||
data: {
|
||||
fulfilledQuantity: fulfilled,
|
||||
...(fulfilled >= need.requestedQuantity && need.status === 'PUBLISHED'
|
||||
? { status: 'FULFILLED', revision: { increment: 1 } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!donation) return;
|
||||
if (input.targetStatus === 'SORTED' && donation.status === 'RECEIVED') {
|
||||
await tx.oldItemDonation.update({
|
||||
where: { id: current.sourceDonationId! },
|
||||
data: { status: 'PROCESSING', revision: { increment: 1 } },
|
||||
});
|
||||
await tx.oldItemDonationStatusHistory.create({
|
||||
data: {
|
||||
donationId: current.sourceDonationId!,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
fromStatus: 'RECEIVED',
|
||||
toStatus: 'PROCESSING',
|
||||
operatorUserId: actor.userId,
|
||||
createdAt: eventAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (input.targetStatus === 'DELIVERED' && donation.status !== 'COMPLETED') {
|
||||
const allowed = donation.status === 'PROCESSING' || donation.status === 'RECEIVED';
|
||||
if (!allowed) throw this.invalid();
|
||||
await tx.oldItemDonation.update({
|
||||
where: { id: current.sourceDonationId! },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
revision: { increment: 1 },
|
||||
completedAt: eventAt,
|
||||
assistedPeople: input.assistedPeople ?? 0,
|
||||
},
|
||||
});
|
||||
await tx.oldItemDonationStatusHistory.create({
|
||||
data: {
|
||||
donationId: current.sourceDonationId!,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
fromStatus: donation.status,
|
||||
toStatus: 'COMPLETED',
|
||||
operatorUserId: actor.userId,
|
||||
createdAt: eventAt,
|
||||
},
|
||||
});
|
||||
await this.rewards.grantOnCompletion(
|
||||
tx,
|
||||
current.sourceDonationId!,
|
||||
input.clientRequestId,
|
||||
eventAt,
|
||||
actor,
|
||||
req,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async find(publicId: string) {
|
||||
const item = await this.prisma.oldItemInventoryItem.findUnique({
|
||||
where: { publicId },
|
||||
include: INCLUDE,
|
||||
});
|
||||
if (!item)
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.RESOURCE_NOT_FOUND,
|
||||
'未找到该物资台账',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
return item;
|
||||
}
|
||||
private map(x: InventoryWithRelations): OldItemInventoryItem {
|
||||
return {
|
||||
publicId: x.publicId,
|
||||
inventoryNo: x.inventoryNo,
|
||||
sourceDonation: x.sourceDonation
|
||||
? { publicId: x.sourceDonation.publicId, donationNo: x.sourceDonation.donationNo }
|
||||
: null,
|
||||
category: x.category,
|
||||
itemName: x.itemName,
|
||||
quantity: x.quantity,
|
||||
unit: x.unit,
|
||||
storageLocation: x.storageLocation,
|
||||
status: x.status,
|
||||
targetNeed: x.targetNeed,
|
||||
beneficiaryPublicId: x.beneficiary?.publicId ?? null,
|
||||
destinationRegion: x.destinationRegion,
|
||||
note: x.noteEncrypted ? this.sensitive.decrypt(x.noteEncrypted) : null,
|
||||
landingAt: x.landingAt?.toISOString() ?? null,
|
||||
revision: x.revision,
|
||||
createdAt: x.createdAt.toISOString(),
|
||||
events: x.events.map((e) => ({
|
||||
eventType: e.eventType,
|
||||
fromStatus: e.fromStatus,
|
||||
status: e.toStatus,
|
||||
locationText: e.locationText,
|
||||
note: e.noteEncrypted ? this.sensitive.decrypt(e.noteEncrypted) : null,
|
||||
operatorName: e.operator.staffProfile?.displayName ?? e.operator.nickname ?? '工作人员',
|
||||
eventAt: e.eventAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
private validateImportDuplicates(rows: OldItemInventoryImportRowDto[]) {
|
||||
const numbers = rows.map((x) => x.sourceDonationNo).filter((x): x is string => Boolean(x));
|
||||
if (new Set(numbers).size !== numbers.length)
|
||||
throw this.validation('同一导入文件不能重复绑定来源捐赠编号');
|
||||
}
|
||||
private eventAt(value?: string) {
|
||||
const date = value ? new Date(value) : new Date();
|
||||
if (Number.isNaN(date.getTime()) || date.getTime() > Date.now() + 300_000)
|
||||
throw this.validation('流转时间无效或晚于当前时间');
|
||||
return date;
|
||||
}
|
||||
private inventoryNo() {
|
||||
return `INV-${new Date().toISOString().slice(0, 10).replaceAll('-', '')}-${randomBytes(5).toString('hex').toUpperCase()}`;
|
||||
}
|
||||
private derivedRequestId(requestId: string, index: number) {
|
||||
const hex = createHash('sha256').update(`${requestId}:${index}`).digest('hex').slice(0, 32);
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
private fingerprint(domain: string, value: object) {
|
||||
return this.sensitive.lookupHash(domain, JSON.stringify(value));
|
||||
}
|
||||
private validation(message: string) {
|
||||
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
private duplicate() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.DUPLICATE_REQUEST,
|
||||
'请求标识已用于其他操作',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
private conflict() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.RESOURCE_CONFLICT,
|
||||
'台账已被更新,请刷新后重试',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
private invalid() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.INVALID_STATE_TRANSITION,
|
||||
'当前台账状态不能执行该操作',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
OLD_ITEM_CATEGORY_LABELS,
|
||||
type OldItemCertificateItem,
|
||||
type OldItemPointAccountItem,
|
||||
type OldItemPointEntryItem,
|
||||
type OldItemRewardConfig,
|
||||
} from '@lehuo/contracts';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { ApplicationError } from '../common/application-error';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import type { OldItemCategory, Prisma } from '../generated/prisma/client';
|
||||
import { SensitiveDataService } from '../security/sensitive-data.service';
|
||||
import type {
|
||||
ActivateOldItemRewardConfigDto,
|
||||
AdjustOldItemPointsDto,
|
||||
IssueOldItemCertificateDto,
|
||||
OldItemPointAccountQueryDto,
|
||||
RevokeOldItemCertificateDto,
|
||||
} from './dto/old-items.dto';
|
||||
|
||||
const DEFAULT_CONFIG: OldItemRewardConfig = {
|
||||
publicId: null,
|
||||
version: 0,
|
||||
certificateTitle: '爱心捐赠纪念证书',
|
||||
certificateMessage:
|
||||
'感谢 {recipient} 完成公益编号 {donationNo} 的旧物捐赠,共捐赠 {quantity} 件{category}物资。您的善意让闲置物品重新拥有温度。',
|
||||
issuerName: '乐活公益',
|
||||
legalNotice: '本证书仅用于记录公益参与,不作为捐赠收据、税务凭证或任何财产权利证明。',
|
||||
clothingPoints: 1,
|
||||
booksPoints: 1,
|
||||
householdPoints: 1,
|
||||
sportsPoints: 1,
|
||||
maxPointsPerDonation: 500,
|
||||
createdAt: null,
|
||||
};
|
||||
const ALLOWED_PLACEHOLDERS = new Set([
|
||||
'recipient',
|
||||
'donationNo',
|
||||
'quantity',
|
||||
'category',
|
||||
'completedDate',
|
||||
]);
|
||||
const CERTIFICATE_INCLUDE = { donation: true } satisfies Prisma.OldItemCertificateInclude;
|
||||
type CertificateWithDonation = Prisma.OldItemCertificateGetPayload<{
|
||||
include: typeof CERTIFICATE_INCLUDE;
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class OldItemRewardsService {
|
||||
constructor(
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
|
||||
@Inject(AuditService) private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async config() {
|
||||
const active = await this.prisma.oldItemRewardConfig.findUnique({
|
||||
where: { activeKey: 'ACTIVE' },
|
||||
});
|
||||
return { config: active ? this.mapConfig(active) : DEFAULT_CONFIG, usesDefault: !active };
|
||||
}
|
||||
|
||||
async activateConfig(
|
||||
input: ActivateOldItemRewardConfigDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
this.validateTemplate(input.certificateMessage);
|
||||
const fp = this.fingerprint('old-item-reward-config', input);
|
||||
const replay = await this.prisma.oldItemRewardConfig.findUnique({
|
||||
where: { clientRequestId: input.clientRequestId },
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { config: this.mapConfig(replay), idempotentReplay: true };
|
||||
}
|
||||
const config = await this.prisma.$transaction(async (tx) => {
|
||||
const latest = await tx.oldItemRewardConfig.aggregate({ _max: { version: true } });
|
||||
await tx.oldItemRewardConfig.updateMany({
|
||||
where: { activeKey: 'ACTIVE' },
|
||||
data: { activeKey: null },
|
||||
});
|
||||
const created = await tx.oldItemRewardConfig.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
clientRequestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
version: (latest._max.version ?? 0) + 1,
|
||||
activeKey: 'ACTIVE',
|
||||
certificateTitle: input.certificateTitle.trim(),
|
||||
certificateMessage: input.certificateMessage.trim(),
|
||||
issuerName: input.issuerName.trim(),
|
||||
legalNotice: input.legalNotice.trim(),
|
||||
clothingPoints: input.clothingPoints,
|
||||
booksPoints: input.booksPoints,
|
||||
householdPoints: input.householdPoints,
|
||||
sportsPoints: input.sportsPoints,
|
||||
maxPointsPerDonation: input.maxPointsPerDonation,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_REWARD_CONFIG_ACTIVATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_REWARD_CONFIG',
|
||||
resourcePublicId: created.publicId,
|
||||
metadata: { version: created.version },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return created;
|
||||
});
|
||||
return { config: this.mapConfig(config), idempotentReplay: false };
|
||||
}
|
||||
|
||||
async grantOnCompletion(
|
||||
tx: Prisma.TransactionClient,
|
||||
donationId: bigint,
|
||||
completionRequestId: string,
|
||||
completedAt: Date,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const donation = await tx.oldItemDonation.findUniqueOrThrow({
|
||||
where: { id: donationId },
|
||||
include: { donor: true },
|
||||
});
|
||||
const config = await tx.oldItemRewardConfig.findUnique({ where: { activeKey: 'ACTIVE' } });
|
||||
const view = config ? this.mapConfig(config) : DEFAULT_CONFIG;
|
||||
const activeCertificate = await tx.oldItemCertificate.findFirst({
|
||||
where: { donationId, activeKey: 'ACTIVE' },
|
||||
});
|
||||
if (!activeCertificate) {
|
||||
const certificate = await this.createCertificate(
|
||||
tx,
|
||||
donation,
|
||||
config,
|
||||
view,
|
||||
this.derivedRequestId(completionRequestId, 'certificate'),
|
||||
actor.userId,
|
||||
completedAt,
|
||||
);
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_CERTIFICATE_ISSUED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_CERTIFICATE',
|
||||
resourcePublicId: certificate.publicId,
|
||||
metadata: { source: 'DONATION_COMPLETED' },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
const sourceKey = `DONATION:${donation.publicId}`;
|
||||
const existingPoints = await tx.oldItemPointEntry.findUnique({ where: { sourceKey } });
|
||||
const points = this.points(view, donation.category, donation.quantity);
|
||||
if (!existingPoints && points > 0) {
|
||||
const account = await this.lockAccount(tx, donation.donorUserId);
|
||||
const balanceAfter = account.balance + points;
|
||||
await tx.oldItemPointAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { balance: balanceAfter, revision: { increment: 1 } },
|
||||
});
|
||||
const entry = await tx.oldItemPointEntry.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
accountId: account.id,
|
||||
userId: donation.donorUserId,
|
||||
donationId,
|
||||
configId: config?.id,
|
||||
requestId: this.derivedRequestId(completionRequestId, 'points'),
|
||||
requestFingerprint: this.fingerprint('old-item-points-earned', {
|
||||
donationPublicId: donation.publicId,
|
||||
points,
|
||||
}),
|
||||
sourceKey,
|
||||
entryType: 'EARNED',
|
||||
pointsDelta: points,
|
||||
balanceAfter,
|
||||
noteEncrypted: this.sensitive.encrypt('旧物捐赠完成自动获得公益荣誉积分'),
|
||||
operatorUserId: actor.userId,
|
||||
createdAt: completedAt,
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_POINTS_EARNED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_POINT_ENTRY',
|
||||
resourcePublicId: entry.publicId,
|
||||
metadata: { points, source: 'DONATION_COMPLETED' },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async myRewards(actor: AuthContext) {
|
||||
const [account, certificates, entries] = await this.prisma.$transaction([
|
||||
this.prisma.oldItemPointAccount.findUnique({ where: { userId: actor.userId } }),
|
||||
this.prisma.oldItemCertificate.findMany({
|
||||
where: { donation: { donorUserId: actor.userId } },
|
||||
include: CERTIFICATE_INCLUDE,
|
||||
orderBy: { issuedAt: 'desc' },
|
||||
take: 50,
|
||||
}),
|
||||
this.prisma.oldItemPointEntry.findMany({
|
||||
where: { userId: actor.userId },
|
||||
include: { donation: { select: { donationNo: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
balance: account?.balance ?? 0,
|
||||
certificates: certificates.map((x) => this.mapCertificate(x, true)),
|
||||
entries: entries.map((x) => this.mapPointEntry(x, true)),
|
||||
};
|
||||
}
|
||||
|
||||
async myCertificate(publicId: string, actor: AuthContext) {
|
||||
const certificate = await this.prisma.oldItemCertificate.findFirst({
|
||||
where: { publicId, donation: { donorUserId: actor.userId } },
|
||||
include: CERTIFICATE_INCLUDE,
|
||||
});
|
||||
if (!certificate) throw this.notFound('未找到该电子证书');
|
||||
return { certificate: this.mapCertificate(certificate, true) };
|
||||
}
|
||||
|
||||
async accounts(q: OldItemPointAccountQueryDto) {
|
||||
const where: Prisma.OldItemPointAccountWhereInput = q.keyword
|
||||
? {
|
||||
OR: [
|
||||
{ publicId: { contains: q.keyword } },
|
||||
{ user: { publicId: { contains: q.keyword } } },
|
||||
{ user: { nickname: { contains: q.keyword } } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
const [total, accounts] = await this.prisma.$transaction([
|
||||
this.prisma.oldItemPointAccount.count({ where }),
|
||||
this.prisma.oldItemPointAccount.findMany({
|
||||
where,
|
||||
include: { user: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip: (q.page - 1) * q.pageSize,
|
||||
take: q.pageSize,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
items: accounts.map((x): OldItemPointAccountItem => ({
|
||||
publicId: x.publicId,
|
||||
userPublicId: x.user.publicId,
|
||||
displayName: x.user.nickname ?? '爱心用户',
|
||||
balance: x.balance,
|
||||
revision: x.revision,
|
||||
updatedAt: x.updatedAt.toISOString(),
|
||||
})),
|
||||
page: q.page,
|
||||
pageSize: q.pageSize,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async certificates(q: OldItemPointAccountQueryDto) {
|
||||
const where: Prisma.OldItemCertificateWhereInput = q.keyword
|
||||
? {
|
||||
OR: [
|
||||
{ certificateNo: { contains: q.keyword } },
|
||||
{ donation: { donationNo: { contains: q.keyword } } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
const [total, certificates] = await this.prisma.$transaction([
|
||||
this.prisma.oldItemCertificate.count({ where }),
|
||||
this.prisma.oldItemCertificate.findMany({
|
||||
where,
|
||||
include: CERTIFICATE_INCLUDE,
|
||||
orderBy: { issuedAt: 'desc' },
|
||||
skip: (q.page - 1) * q.pageSize,
|
||||
take: q.pageSize,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
items: certificates.map((x) => this.mapCertificate(x, false)),
|
||||
page: q.page,
|
||||
pageSize: q.pageSize,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async issue(
|
||||
donationPublicId: string,
|
||||
input: IssueOldItemCertificateDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const fp = this.fingerprint('old-item-certificate-issue', { donationPublicId, ...input });
|
||||
const replay = await this.prisma.oldItemCertificate.findUnique({
|
||||
where: { issueRequestId: input.clientRequestId },
|
||||
include: CERTIFICATE_INCLUDE,
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.issueFingerprint !== fp) throw this.duplicate();
|
||||
return { certificate: this.mapCertificate(replay, false), idempotentReplay: true };
|
||||
}
|
||||
const donation = await this.prisma.oldItemDonation.findUnique({
|
||||
where: { publicId: donationPublicId },
|
||||
include: { donor: true },
|
||||
});
|
||||
if (!donation) throw this.notFound('未找到该捐赠记录');
|
||||
if (donation.status !== 'COMPLETED' || !donation.completedAt)
|
||||
throw this.invalid('捐赠完成后才能补发证书');
|
||||
const certificate = await this.prisma.$transaction(async (tx) => {
|
||||
const config = await tx.oldItemRewardConfig.findUnique({ where: { activeKey: 'ACTIVE' } });
|
||||
const active = await tx.oldItemCertificate.findFirst({
|
||||
where: { donationId: donation.id, activeKey: 'ACTIVE' },
|
||||
});
|
||||
if (active) {
|
||||
await tx.oldItemCertificate.update({
|
||||
where: { id: active.id },
|
||||
data: {
|
||||
activeKey: null,
|
||||
status: 'REVOKED',
|
||||
revokedById: actor.userId,
|
||||
revokedRequestId: this.derivedRequestId(input.clientRequestId, 'reissue-revoke'),
|
||||
revokedFingerprint: fp,
|
||||
revokeReasonEncrypted: this.sensitive.encrypt('因后台补发新证书自动撤销旧证书'),
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
const created = await this.createCertificate(
|
||||
tx,
|
||||
donation,
|
||||
config,
|
||||
config ? this.mapConfig(config) : DEFAULT_CONFIG,
|
||||
input.clientRequestId,
|
||||
actor.userId,
|
||||
new Date(),
|
||||
fp,
|
||||
);
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: active ? 'OLD_ITEM_CERTIFICATE_REISSUED' : 'OLD_ITEM_CERTIFICATE_ISSUED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_CERTIFICATE',
|
||||
resourcePublicId: created.publicId,
|
||||
metadata: { donationPublicId, replacedCertificate: Boolean(active) },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return tx.oldItemCertificate.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: CERTIFICATE_INCLUDE,
|
||||
});
|
||||
});
|
||||
return { certificate: this.mapCertificate(certificate, false), idempotentReplay: false };
|
||||
}
|
||||
|
||||
async revoke(
|
||||
publicId: string,
|
||||
input: RevokeOldItemCertificateDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const fp = this.fingerprint('old-item-certificate-revoke', { publicId, ...input });
|
||||
const replay = await this.prisma.oldItemCertificate.findUnique({
|
||||
where: { revokedRequestId: input.clientRequestId },
|
||||
include: CERTIFICATE_INCLUDE,
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.revokedFingerprint !== fp) throw this.duplicate();
|
||||
return { certificate: this.mapCertificate(replay, false), idempotentReplay: true };
|
||||
}
|
||||
const current = await this.prisma.oldItemCertificate.findUnique({
|
||||
where: { publicId },
|
||||
include: CERTIFICATE_INCLUDE,
|
||||
});
|
||||
if (!current) throw this.notFound('未找到该电子证书');
|
||||
if (current.status !== 'ISSUED') throw this.invalid('该证书已撤销');
|
||||
const certificate = await this.prisma.$transaction(async (tx) => {
|
||||
const changed = await tx.oldItemCertificate.updateMany({
|
||||
where: { id: current.id, status: 'ISSUED' },
|
||||
data: {
|
||||
activeKey: null,
|
||||
status: 'REVOKED',
|
||||
revokedById: actor.userId,
|
||||
revokedRequestId: input.clientRequestId,
|
||||
revokedFingerprint: fp,
|
||||
revokeReasonEncrypted: this.sensitive.encrypt(input.reason.trim()),
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (changed.count !== 1) throw this.conflict();
|
||||
const updated = await tx.oldItemCertificate.findUniqueOrThrow({
|
||||
where: { id: current.id },
|
||||
include: CERTIFICATE_INCLUDE,
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_CERTIFICATE_REVOKED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_CERTIFICATE',
|
||||
resourcePublicId: publicId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
return { certificate: this.mapCertificate(certificate, false), idempotentReplay: false };
|
||||
}
|
||||
|
||||
async adjust(
|
||||
userPublicId: string,
|
||||
input: AdjustOldItemPointsDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
if (input.pointsDelta === 0) throw this.validation('积分调整值不能为 0');
|
||||
const fp = this.fingerprint('old-item-points-adjust', { userPublicId, ...input });
|
||||
const replay = await this.prisma.oldItemPointEntry.findUnique({
|
||||
where: { requestId: input.clientRequestId },
|
||||
include: { donation: { select: { donationNo: true } } },
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { entry: this.mapPointEntry(replay, false), idempotentReplay: true };
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { publicId: userPublicId } });
|
||||
if (!user) throw this.notFound('未找到该用户');
|
||||
const entry = await this.prisma.$transaction(async (tx) => {
|
||||
const account = await this.lockAccount(tx, user.id);
|
||||
if (account.revision !== input.expectedRevision) throw this.conflict();
|
||||
const balanceAfter = account.balance + input.pointsDelta;
|
||||
if (balanceAfter < 0) throw this.validation('扣减后积分不能小于 0');
|
||||
const changed = await tx.oldItemPointAccount.updateMany({
|
||||
where: { id: account.id, revision: input.expectedRevision },
|
||||
data: { balance: balanceAfter, revision: { increment: 1 } },
|
||||
});
|
||||
if (changed.count !== 1) throw this.conflict();
|
||||
const created = await tx.oldItemPointEntry.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
accountId: account.id,
|
||||
userId: user.id,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
sourceKey: `ADJUSTMENT:${input.clientRequestId}`,
|
||||
entryType: 'ADJUSTMENT',
|
||||
pointsDelta: input.pointsDelta,
|
||||
balanceAfter,
|
||||
noteEncrypted: this.sensitive.encrypt(input.reason.trim()),
|
||||
operatorUserId: actor.userId,
|
||||
},
|
||||
include: { donation: { select: { donationNo: true } } },
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_POINTS_ADJUSTED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_POINT_ENTRY',
|
||||
resourcePublicId: created.publicId,
|
||||
metadata: { pointsDelta: input.pointsDelta },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return created;
|
||||
});
|
||||
return { entry: this.mapPointEntry(entry, false), idempotentReplay: false };
|
||||
}
|
||||
|
||||
private async createCertificate(
|
||||
tx: Prisma.TransactionClient,
|
||||
donation: Prisma.OldItemDonationGetPayload<{ include: { donor: true } }>,
|
||||
config: Prisma.OldItemRewardConfigGetPayload<object> | null,
|
||||
view: OldItemRewardConfig,
|
||||
requestId: string,
|
||||
actorId: bigint,
|
||||
issuedAt: Date,
|
||||
fingerprint?: string,
|
||||
) {
|
||||
const recipient = donation.donor.nickname?.trim() || '爱心用户';
|
||||
return tx.oldItemCertificate.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
certificateNo: this.certificateNo(),
|
||||
donationId: donation.id,
|
||||
configId: config?.id,
|
||||
issueRequestId: requestId,
|
||||
issueFingerprint:
|
||||
fingerprint ??
|
||||
this.fingerprint('old-item-certificate-auto', {
|
||||
donationPublicId: donation.publicId,
|
||||
requestId,
|
||||
}),
|
||||
activeKey: 'ACTIVE',
|
||||
recipientNameEncrypted: this.sensitive.encrypt(recipient),
|
||||
titleSnapshot: view.certificateTitle,
|
||||
messageSnapshot: this.render(view.certificateMessage, {
|
||||
recipient,
|
||||
donationNo: donation.donationNo,
|
||||
quantity: String(donation.quantity),
|
||||
category: OLD_ITEM_CATEGORY_LABELS[donation.category],
|
||||
completedDate: (donation.completedAt ?? issuedAt).toISOString().slice(0, 10),
|
||||
}),
|
||||
issuerSnapshot: view.issuerName,
|
||||
legalNoticeSnapshot: view.legalNotice,
|
||||
issuedById: actorId,
|
||||
issuedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async lockAccount(tx: Prisma.TransactionClient, userId: bigint) {
|
||||
await tx.oldItemPointAccount.upsert({
|
||||
where: { userId },
|
||||
update: {},
|
||||
create: { publicId: randomUUID(), userId },
|
||||
});
|
||||
await tx.$queryRaw`SELECT id FROM old_item_point_accounts WHERE user_id = ${userId} FOR UPDATE`;
|
||||
return tx.oldItemPointAccount.findUniqueOrThrow({ where: { userId } });
|
||||
}
|
||||
|
||||
private points(config: OldItemRewardConfig, category: OldItemCategory, quantity: number) {
|
||||
const perItem = {
|
||||
CLOTHING: config.clothingPoints,
|
||||
BOOKS: config.booksPoints,
|
||||
HOUSEHOLD: config.householdPoints,
|
||||
SPORTS: config.sportsPoints,
|
||||
}[category];
|
||||
return Math.min(config.maxPointsPerDonation, perItem * quantity);
|
||||
}
|
||||
|
||||
private mapConfig(x: Prisma.OldItemRewardConfigGetPayload<object>): OldItemRewardConfig {
|
||||
return {
|
||||
publicId: x.publicId,
|
||||
version: x.version,
|
||||
certificateTitle: x.certificateTitle,
|
||||
certificateMessage: x.certificateMessage,
|
||||
issuerName: x.issuerName,
|
||||
legalNotice: x.legalNotice,
|
||||
clothingPoints: x.clothingPoints,
|
||||
booksPoints: x.booksPoints,
|
||||
householdPoints: x.householdPoints,
|
||||
sportsPoints: x.sportsPoints,
|
||||
maxPointsPerDonation: x.maxPointsPerDonation,
|
||||
createdAt: x.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private mapCertificate(
|
||||
x: CertificateWithDonation,
|
||||
revealRecipient: boolean,
|
||||
): OldItemCertificateItem {
|
||||
return {
|
||||
publicId: x.publicId,
|
||||
certificateNo: x.certificateNo,
|
||||
donationPublicId: x.donation.publicId,
|
||||
donationNo: x.donation.donationNo,
|
||||
status: x.status,
|
||||
recipientName: revealRecipient
|
||||
? this.sensitive.decrypt(x.recipientNameEncrypted)
|
||||
: '爱心用户',
|
||||
title: x.titleSnapshot,
|
||||
message: x.messageSnapshot,
|
||||
issuerName: x.issuerSnapshot,
|
||||
legalNotice: x.legalNoticeSnapshot,
|
||||
issuedAt: x.issuedAt.toISOString(),
|
||||
revokedAt: x.revokedAt?.toISOString() ?? null,
|
||||
revokeReason:
|
||||
revealRecipient && x.revokeReasonEncrypted
|
||||
? this.sensitive.decrypt(x.revokeReasonEncrypted)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private mapPointEntry(
|
||||
x: Prisma.OldItemPointEntryGetPayload<{
|
||||
include: { donation: { select: { donationNo: true } } };
|
||||
}>,
|
||||
revealNote: boolean,
|
||||
): OldItemPointEntryItem {
|
||||
return {
|
||||
publicId: x.publicId,
|
||||
entryType: x.entryType,
|
||||
pointsDelta: x.pointsDelta,
|
||||
balanceAfter: x.balanceAfter,
|
||||
donationNo: x.donation?.donationNo ?? null,
|
||||
note: revealNote && x.noteEncrypted ? this.sensitive.decrypt(x.noteEncrypted) : null,
|
||||
createdAt: x.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private validateTemplate(template: string) {
|
||||
for (const match of template.matchAll(/\{([^{}]+)\}/g)) {
|
||||
if (!ALLOWED_PLACEHOLDERS.has(match[1]!))
|
||||
throw this.validation(`证书模板包含不支持的占位符:{${match[1]}}`);
|
||||
}
|
||||
}
|
||||
private render(template: string, values: Record<string, string>) {
|
||||
return template.replace(/\{([^{}]+)\}/g, (all, key: string) => values[key] ?? all);
|
||||
}
|
||||
private certificateNo() {
|
||||
return `CERT-${new Date().toISOString().slice(0, 10).replaceAll('-', '')}-${randomBytes(6).toString('hex').toUpperCase()}`;
|
||||
}
|
||||
private derivedRequestId(requestId: string, domain: string) {
|
||||
const hex = createHash('sha256').update(`${domain}:${requestId}`).digest('hex').slice(0, 32);
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
private fingerprint(domain: string, value: object) {
|
||||
return this.sensitive.lookupHash(domain, JSON.stringify(value));
|
||||
}
|
||||
private validation(message: string) {
|
||||
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
private duplicate() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.DUPLICATE_REQUEST,
|
||||
'请求标识已用于其他操作',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
private conflict() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.RESOURCE_CONFLICT,
|
||||
'积分账户已更新,请刷新后重试',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
private invalid(message: string) {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.INVALID_STATE_TRANSITION,
|
||||
message,
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
private notFound(message: string) {
|
||||
return new ApplicationError(API_ERROR_CODES.RESOURCE_NOT_FOUND, message, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
ValidationPipe,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
PERMISSION_CODES,
|
||||
type ConfirmUploadResponse,
|
||||
type CreateUploadIntentResponse,
|
||||
} from '@lehuo/contracts';
|
||||
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
|
||||
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
|
||||
import { ApplicationError } from '../common/application-error';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { SessionChannel } from '../generated/prisma/enums';
|
||||
import { MediaService } from '../participation/media.service';
|
||||
import {
|
||||
CreateOldItemDonationDto,
|
||||
CreateOldItemMediaUploadIntentDto,
|
||||
CreateOldItemDropOffPointDto,
|
||||
CreateOldItemNeedDto,
|
||||
OldItemDonationQueryDto,
|
||||
OldItemNeedQueryDto,
|
||||
TransitionOldItemDonationDto,
|
||||
TransitionOldItemNeedDto,
|
||||
UpdateOldItemNeedDto,
|
||||
CreateOldItemInventoryDto,
|
||||
ImportOldItemInventoryDto,
|
||||
OldItemInventoryQueryDto,
|
||||
TransitionOldItemInventoryDto,
|
||||
ActivateOldItemRewardConfigDto,
|
||||
AdjustOldItemPointsDto,
|
||||
IssueOldItemCertificateDto,
|
||||
OldItemPointAccountQueryDto,
|
||||
RevokeOldItemCertificateDto,
|
||||
} from './dto/old-items.dto';
|
||||
import { OldItemInventoryService } from './old-item-inventory.service';
|
||||
import { OldItemRewardsService } from './old-item-rewards.service';
|
||||
import { OldItemsService } from './old-items.service';
|
||||
const strict = (expectedType: new () => object) =>
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType,
|
||||
});
|
||||
|
||||
@Controller('api/v1/old-items')
|
||||
export class OldItemsController {
|
||||
constructor(
|
||||
@Inject(OldItemsService) private readonly service: OldItemsService,
|
||||
@Inject(MediaService) private readonly media: MediaService,
|
||||
@Inject(OldItemRewardsService) private readonly rewards: OldItemRewardsService,
|
||||
) {}
|
||||
@Get('stats') @Public() stats() {
|
||||
return this.service.stats();
|
||||
}
|
||||
@Get('needs') @Public() needs(@Query(strict(OldItemNeedQueryDto)) q: OldItemNeedQueryDto) {
|
||||
return this.service.listNeeds(q);
|
||||
}
|
||||
@Get('needs/:publicId') @Public() need(@Param('publicId') id: string) {
|
||||
return this.service.getNeed(id);
|
||||
}
|
||||
@Get('drop-off-points') @Public() points() {
|
||||
return this.service.dropOffPoints();
|
||||
}
|
||||
@Post('media/upload-intents') @RequireSessionChannel(SessionChannel.WECHAT) mediaIntent(
|
||||
@Body(strict(CreateOldItemMediaUploadIntentDto)) input: CreateOldItemMediaUploadIntentDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
): Promise<CreateUploadIntentResponse> {
|
||||
if (input.businessType !== 'OLD_ITEM_DONATION_IMAGE')
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.VALIDATION_ERROR,
|
||||
'图片业务类型无效',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
return this.media.createIntent(input, actor, req);
|
||||
}
|
||||
@Post('media/:publicId/confirm') @RequireSessionChannel(SessionChannel.WECHAT) confirm(
|
||||
@Param('publicId') id: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
): Promise<ConfirmUploadResponse> {
|
||||
return this.media.confirm(id, actor, req);
|
||||
}
|
||||
@Post('donations') @RequireSessionChannel(SessionChannel.WECHAT) create(
|
||||
@Body(strict(CreateOldItemDonationDto)) input: CreateOldItemDonationDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.createDonation(input, actor, req);
|
||||
}
|
||||
@Get('me/donations') @RequireSessionChannel(SessionChannel.WECHAT) mine(
|
||||
@Query(strict(OldItemDonationQueryDto)) q: OldItemDonationQueryDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
) {
|
||||
return this.service.myDonations(q, actor);
|
||||
}
|
||||
@Get('me/donations/:publicId') @RequireSessionChannel(SessionChannel.WECHAT) mineOne(
|
||||
@Param('publicId') id: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
) {
|
||||
return this.service.myDonation(id, actor);
|
||||
}
|
||||
@Get('me/media/:publicId/download')
|
||||
@RequireSessionChannel(SessionChannel.WECHAT)
|
||||
image(
|
||||
@Param('publicId') id: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.imageDownload(id, actor, req);
|
||||
}
|
||||
@Get('me/rewards') @RequireSessionChannel(SessionChannel.WECHAT) myRewards(
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
) {
|
||||
return this.rewards.myRewards(actor);
|
||||
}
|
||||
@Get('me/certificates/:publicId')
|
||||
@RequireSessionChannel(SessionChannel.WECHAT)
|
||||
myCertificate(@Param('publicId') id: string, @CurrentAuth() actor: AuthContext) {
|
||||
return this.rewards.myCertificate(id, actor);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('api/v1/admin/old-items')
|
||||
@RequireSessionChannel(SessionChannel.ADMIN)
|
||||
export class AdminOldItemsController {
|
||||
constructor(
|
||||
@Inject(OldItemsService) private readonly service: OldItemsService,
|
||||
@Inject(OldItemInventoryService) private readonly inventory: OldItemInventoryService,
|
||||
@Inject(OldItemRewardsService) private readonly rewards: OldItemRewardsService,
|
||||
) {}
|
||||
@Get('assignees') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) assignees() {
|
||||
return this.service.assignees();
|
||||
}
|
||||
@Get('media/:publicId/download') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) image(
|
||||
@Param('publicId') id: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.imageDownload(id, actor, req, true);
|
||||
}
|
||||
@Get('donations') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) donations(
|
||||
@Query(strict(OldItemDonationQueryDto)) q: OldItemDonationQueryDto,
|
||||
) {
|
||||
return this.service.adminDonations(q);
|
||||
}
|
||||
@Get('donations/:publicId') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) donation(
|
||||
@Param('publicId') id: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.adminDonation(id, actor, req);
|
||||
}
|
||||
@Post('donations/:publicId/transition')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEMS_MANAGE)
|
||||
transition(
|
||||
@Param('publicId') id: string,
|
||||
@Body(strict(TransitionOldItemDonationDto)) input: TransitionOldItemDonationDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.transitionDonation(id, input, actor, req);
|
||||
}
|
||||
@Get('needs') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) needs(
|
||||
@Query(strict(OldItemNeedQueryDto)) q: OldItemNeedQueryDto,
|
||||
) {
|
||||
return this.service.listNeeds(q, true);
|
||||
}
|
||||
@Get('needs/:publicId') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) need(
|
||||
@Param('publicId') id: string,
|
||||
) {
|
||||
return this.service.getNeed(id, true);
|
||||
}
|
||||
@Post('needs') @RequirePermissions(PERMISSION_CODES.OLD_ITEM_NEEDS_MANAGE) createNeed(
|
||||
@Body(strict(CreateOldItemNeedDto)) input: CreateOldItemNeedDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.createNeed(input, actor, req);
|
||||
}
|
||||
@Patch('needs/:publicId') @RequirePermissions(PERMISSION_CODES.OLD_ITEM_NEEDS_MANAGE) updateNeed(
|
||||
@Param('publicId') id: string,
|
||||
@Body(strict(UpdateOldItemNeedDto)) input: UpdateOldItemNeedDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.updateNeed(id, input, actor, req);
|
||||
}
|
||||
@Post('needs/:publicId/transition')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_NEEDS_MANAGE)
|
||||
transitionNeed(
|
||||
@Param('publicId') id: string,
|
||||
@Body(strict(TransitionOldItemNeedDto)) input: TransitionOldItemNeedDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.transitionNeed(id, input, actor, req);
|
||||
}
|
||||
@Post('drop-off-points') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_MANAGE) createPoint(
|
||||
@Body(strict(CreateOldItemDropOffPointDto)) input: CreateOldItemDropOffPointDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.createDropOffPoint(input, actor, req);
|
||||
}
|
||||
|
||||
@Get('inventory')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_READ)
|
||||
inventoryList(@Query(strict(OldItemInventoryQueryDto)) q: OldItemInventoryQueryDto) {
|
||||
return this.inventory.list(q);
|
||||
}
|
||||
|
||||
@Get('inventory/:publicId')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_READ)
|
||||
inventoryOne(
|
||||
@Param('publicId') id: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.inventory.get(id, actor, req);
|
||||
}
|
||||
|
||||
@Post('inventory')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_MANAGE)
|
||||
inventoryCreate(
|
||||
@Body(strict(CreateOldItemInventoryDto)) input: CreateOldItemInventoryDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.inventory.create(input, actor, req);
|
||||
}
|
||||
|
||||
@Post('inventory/import')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_IMPORT)
|
||||
inventoryImport(
|
||||
@Body(strict(ImportOldItemInventoryDto)) input: ImportOldItemInventoryDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.inventory.import(input, actor, req);
|
||||
}
|
||||
|
||||
@Post('inventory/:publicId/transition')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_MANAGE)
|
||||
inventoryTransition(
|
||||
@Param('publicId') id: string,
|
||||
@Body(strict(TransitionOldItemInventoryDto)) input: TransitionOldItemInventoryDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.inventory.transition(id, input, actor, req);
|
||||
}
|
||||
|
||||
@Get('inventory-export/file')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_EXPORT)
|
||||
inventoryExport(
|
||||
@Query(strict(OldItemInventoryQueryDto)) q: OldItemInventoryQueryDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.inventory.export(q, actor, req);
|
||||
}
|
||||
@Get('reward-config')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_READ)
|
||||
rewardConfig() {
|
||||
return this.rewards.config();
|
||||
}
|
||||
@Post('reward-config/activate')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_MANAGE)
|
||||
activateRewardConfig(
|
||||
@Body(strict(ActivateOldItemRewardConfigDto)) input: ActivateOldItemRewardConfigDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.rewards.activateConfig(input, actor, req);
|
||||
}
|
||||
@Get('reward-accounts')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_READ)
|
||||
rewardAccounts(@Query(strict(OldItemPointAccountQueryDto)) q: OldItemPointAccountQueryDto) {
|
||||
return this.rewards.accounts(q);
|
||||
}
|
||||
@Post('reward-accounts/:userPublicId/adjust')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_MANAGE)
|
||||
adjustRewardPoints(
|
||||
@Param('userPublicId') userPublicId: string,
|
||||
@Body(strict(AdjustOldItemPointsDto)) input: AdjustOldItemPointsDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.rewards.adjust(userPublicId, input, actor, req);
|
||||
}
|
||||
@Get('certificates')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_READ)
|
||||
certificates(@Query(strict(OldItemPointAccountQueryDto)) q: OldItemPointAccountQueryDto) {
|
||||
return this.rewards.certificates(q);
|
||||
}
|
||||
@Post('donations/:publicId/certificate')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_MANAGE)
|
||||
issueCertificate(
|
||||
@Param('publicId') publicId: string,
|
||||
@Body(strict(IssueOldItemCertificateDto)) input: IssueOldItemCertificateDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.rewards.issue(publicId, input, actor, req);
|
||||
}
|
||||
@Post('certificates/:publicId/revoke')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_MANAGE)
|
||||
revokeCertificate(
|
||||
@Param('publicId') publicId: string,
|
||||
@Body(strict(RevokeOldItemCertificateDto)) input: RevokeOldItemCertificateDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.rewards.revoke(publicId, input, actor, req);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { ParticipationModule } from '../participation/participation.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { AdminOldItemsController, OldItemsController } from './old-items.controller';
|
||||
import { OldItemsService } from './old-items.service';
|
||||
import { OldItemInventoryService } from './old-item-inventory.service';
|
||||
import { OldItemRewardsService } from './old-item-rewards.service';
|
||||
@Module({
|
||||
imports: [AuditModule, ParticipationModule, FilesModule],
|
||||
controllers: [OldItemsController, AdminOldItemsController],
|
||||
providers: [OldItemsService, OldItemInventoryService, OldItemRewardsService],
|
||||
})
|
||||
export class OldItemsModule {}
|
||||
@@ -0,0 +1,763 @@
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
type OldItemDonationItem,
|
||||
type OldItemNeedItem,
|
||||
type OldItemStats,
|
||||
} from '@lehuo/contracts';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { ApplicationError } from '../common/application-error';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from '../files/object-storage.adapter';
|
||||
import type { OldItemNeed, Prisma } from '../generated/prisma/client';
|
||||
import { SensitiveDataService } from '../security/sensitive-data.service';
|
||||
import type {
|
||||
CreateOldItemDonationDto,
|
||||
CreateOldItemDropOffPointDto,
|
||||
CreateOldItemNeedDto,
|
||||
OldItemDonationQueryDto,
|
||||
OldItemNeedQueryDto,
|
||||
TransitionOldItemDonationDto,
|
||||
TransitionOldItemNeedDto,
|
||||
UpdateOldItemNeedDto,
|
||||
} from './dto/old-items.dto';
|
||||
import { OldItemRewardsService } from './old-item-rewards.service';
|
||||
|
||||
const DONATION_TRANSITIONS: Record<string, string[]> = {
|
||||
SUBMITTED: ['REJECTED', 'WAITING_PICKUP', 'WAITING_DROP_OFF', 'CANCELLED'],
|
||||
WAITING_PICKUP: ['RECEIVED', 'CANCELLED'],
|
||||
WAITING_DROP_OFF: ['RECEIVED', 'CANCELLED'],
|
||||
RECEIVED: ['PROCESSING'],
|
||||
PROCESSING: ['COMPLETED'],
|
||||
REJECTED: [],
|
||||
COMPLETED: [],
|
||||
CANCELLED: [],
|
||||
};
|
||||
const NEED_TRANSITIONS: Record<string, string[]> = {
|
||||
DRAFT: ['PUBLISHED', 'CLOSED'],
|
||||
PUBLISHED: ['FULFILLED', 'CLOSED'],
|
||||
FULFILLED: ['CLOSED'],
|
||||
CLOSED: [],
|
||||
};
|
||||
const DONATION_INCLUDE = {
|
||||
history: { orderBy: { createdAt: 'asc' as const } },
|
||||
targetNeed: true,
|
||||
dropOffPoint: true,
|
||||
assignee: true,
|
||||
donor: true,
|
||||
files: { include: { file: true }, orderBy: { sortOrder: 'asc' as const } },
|
||||
inventoryItems: {
|
||||
include: { events: { orderBy: { eventAt: 'asc' as const } } },
|
||||
orderBy: { createdAt: 'asc' as const },
|
||||
},
|
||||
} satisfies Prisma.OldItemDonationInclude;
|
||||
type DonationWithRelations = Prisma.OldItemDonationGetPayload<{
|
||||
include: typeof DONATION_INCLUDE;
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class OldItemsService {
|
||||
constructor(
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
|
||||
@Inject(AuditService) private readonly audit: AuditService,
|
||||
@Inject(OBJECT_STORAGE_ADAPTER) private readonly storage: ObjectStorageAdapter,
|
||||
@Inject(OldItemRewardsService) private readonly rewards: OldItemRewardsService,
|
||||
) {}
|
||||
|
||||
async assignees() {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { userType: 'STAFF', status: 'ACTIVE' },
|
||||
include: { staffProfile: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return {
|
||||
items: users.map((user) => ({
|
||||
publicId: user.publicId,
|
||||
displayName: user.staffProfile?.displayName ?? user.nickname ?? '工作人员',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async imageDownload(
|
||||
publicId: string,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
admin = false,
|
||||
) {
|
||||
if (!this.storage.configured)
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.STORAGE_UNAVAILABLE,
|
||||
'图片服务尚未配置或暂时不可用',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
const file = await this.prisma.file.findFirst({
|
||||
where: { publicId, status: 'AVAILABLE', businessType: 'OLD_ITEM_DONATION_IMAGE' },
|
||||
include: { oldItemDonations: { include: { donation: true } } },
|
||||
});
|
||||
const donation = file?.oldItemDonations[0]?.donation;
|
||||
if (!file || !donation || (!admin && donation.donorUserId !== actor.userId))
|
||||
throw this.notFound('未找到该捐赠图片');
|
||||
const expiresIn = 300;
|
||||
let downloadUrl: string;
|
||||
try {
|
||||
downloadUrl = await this.storage.createDownloadUrl(file.objectKey, expiresIn);
|
||||
} catch {
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.STORAGE_UNAVAILABLE,
|
||||
'图片服务暂时不可用',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
await this.audit.record(request, {
|
||||
action: 'OLD_ITEM_PRIVATE_IMAGE_URL_CREATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'FILE',
|
||||
resourcePublicId: publicId,
|
||||
});
|
||||
return { downloadUrl, expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString() };
|
||||
}
|
||||
|
||||
async stats(): Promise<OldItemStats> {
|
||||
const month = new Date();
|
||||
month.setDate(1);
|
||||
month.setHours(0, 0, 0, 0);
|
||||
const [items, people, acts] = await this.prisma.$transaction([
|
||||
this.prisma.oldItemDonation.aggregate({
|
||||
_sum: { quantity: true },
|
||||
where: { status: { in: ['RECEIVED', 'PROCESSING', 'COMPLETED'] } },
|
||||
}),
|
||||
this.prisma.oldItemDonation.aggregate({
|
||||
_sum: { assistedPeople: true },
|
||||
where: { status: 'COMPLETED' },
|
||||
}),
|
||||
this.prisma.oldItemDonation.count({ where: { receivedAt: { gte: month } } }),
|
||||
]);
|
||||
return {
|
||||
totalItems: items._sum.quantity ?? 0,
|
||||
assistedPeople: people._sum.assistedPeople ?? 0,
|
||||
monthlyKindActs: acts,
|
||||
};
|
||||
}
|
||||
|
||||
async dropOffPoints() {
|
||||
return {
|
||||
items: await this.prisma.oldItemDropOffPoint.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ sortOrder: 'desc' }, { createdAt: 'desc' }],
|
||||
select: {
|
||||
publicId: true,
|
||||
name: true,
|
||||
regionText: true,
|
||||
addressText: true,
|
||||
openingHours: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async listNeeds(q: OldItemNeedQueryDto, admin = false) {
|
||||
const where: Prisma.OldItemNeedWhereInput = {
|
||||
...(admin ? {} : { status: 'PUBLISHED' }),
|
||||
...(q.status ? { status: q.status as never } : {}),
|
||||
...(q.category ? { category: q.category as never } : {}),
|
||||
};
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.oldItemNeed.count({ where }),
|
||||
this.prisma.oldItemNeed.findMany({
|
||||
where,
|
||||
orderBy: [{ priority: 'desc' }, { sortOrder: 'desc' }, { publishedAt: 'desc' }],
|
||||
skip: (q.page - 1) * q.pageSize,
|
||||
take: q.pageSize,
|
||||
}),
|
||||
]);
|
||||
return { items: items.map((x) => this.need(x)), page: q.page, pageSize: q.pageSize, total };
|
||||
}
|
||||
async getNeed(publicId: string, admin = false) {
|
||||
const x = await this.prisma.oldItemNeed.findUnique({ where: { publicId } });
|
||||
if (!x || (!admin && x.status !== 'PUBLISHED')) throw this.notFound('未找到该物资需求');
|
||||
return { need: this.need(x) };
|
||||
}
|
||||
|
||||
async createDonation(input: CreateOldItemDonationDto, actor: AuthContext, req: RequestContext) {
|
||||
this.validateMethod(input);
|
||||
const fp = this.fingerprint('old-item-donation-create', input);
|
||||
const replay = await this.prisma.oldItemDonation.findUnique({
|
||||
where: {
|
||||
donorUserId_clientRequestId: {
|
||||
donorUserId: actor.userId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
},
|
||||
},
|
||||
include: DONATION_INCLUDE,
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { donation: this.mapDonation(replay, true), idempotentReplay: true };
|
||||
}
|
||||
const [point, need, files] = await Promise.all([
|
||||
input.dropOffPointPublicId
|
||||
? this.prisma.oldItemDropOffPoint.findFirst({
|
||||
where: { publicId: input.dropOffPointPublicId, isActive: true },
|
||||
})
|
||||
: null,
|
||||
input.targetNeedPublicId
|
||||
? this.prisma.oldItemNeed.findFirst({
|
||||
where: { publicId: input.targetNeedPublicId, status: 'PUBLISHED' },
|
||||
})
|
||||
: null,
|
||||
input.imagePublicIds?.length
|
||||
? this.prisma.file.findMany({
|
||||
where: {
|
||||
publicId: { in: input.imagePublicIds },
|
||||
uploaderUserId: actor.userId,
|
||||
status: 'AVAILABLE',
|
||||
businessType: 'OLD_ITEM_DONATION_IMAGE',
|
||||
caseId: null,
|
||||
},
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
if (input.method === 'DROP_OFF' && !point) throw this.validation('请选择有效的定点投递地址');
|
||||
if (input.targetNeedPublicId && !need) throw this.validation('定向需求已下架或不存在');
|
||||
if ((input.imagePublicIds?.length ?? 0) !== files.length || files.length > 6)
|
||||
throw this.validation('捐赠图片无效、未确认或超过 6 张');
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const donation = await tx.oldItemDonation.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
donationNo: this.donationNo(),
|
||||
donorUserId: actor.userId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
category: input.category as never,
|
||||
itemCondition: input.itemCondition as never,
|
||||
quantity: input.quantity,
|
||||
descriptionEncrypted: this.sensitive.encrypt(input.description.trim()),
|
||||
method: input.method,
|
||||
contactNameEncrypted: this.sensitive.encrypt(input.contactName.trim()),
|
||||
mobileEncrypted: this.sensitive.encrypt(input.mobile),
|
||||
mobileHash: this.sensitive.lookupHash('old-item-mobile', input.mobile),
|
||||
addressEncrypted: input.address ? this.sensitive.encrypt(input.address.trim()) : null,
|
||||
appointmentAt: input.appointmentAt ? new Date(input.appointmentAt) : null,
|
||||
dropOffPointId: point?.id,
|
||||
targetNeedId: need?.id,
|
||||
files: { create: files.map((f, i) => ({ fileId: f.id, sortOrder: i })) },
|
||||
},
|
||||
});
|
||||
await tx.oldItemDonationStatusHistory.create({
|
||||
data: {
|
||||
donationId: donation.id,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
toStatus: 'SUBMITTED',
|
||||
operatorUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_DONATION_SUBMITTED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_DONATION',
|
||||
resourcePublicId: donation.publicId,
|
||||
metadata: { category: input.category, method: input.method, quantity: input.quantity },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return tx.oldItemDonation.findUniqueOrThrow({
|
||||
where: { id: donation.id },
|
||||
include: DONATION_INCLUDE,
|
||||
});
|
||||
});
|
||||
return { donation: this.mapDonation(created, true), idempotentReplay: false };
|
||||
}
|
||||
|
||||
async myDonations(q: OldItemDonationQueryDto, actor: AuthContext) {
|
||||
return this.listDonations({ ...q }, { donorUserId: actor.userId }, true);
|
||||
}
|
||||
async myDonation(publicId: string, actor: AuthContext) {
|
||||
const x = await this.findDonation(publicId);
|
||||
if (x.donorUserId !== actor.userId) throw this.notFound('未找到该捐赠记录');
|
||||
return { donation: this.mapDonation(x, true) };
|
||||
}
|
||||
async adminDonations(q: OldItemDonationQueryDto) {
|
||||
const where: Prisma.OldItemDonationWhereInput = {
|
||||
...(q.status ? { status: q.status as never } : {}),
|
||||
...(q.method ? { method: q.method as never } : {}),
|
||||
...(q.keyword
|
||||
? {
|
||||
OR: [
|
||||
{ donationNo: { contains: q.keyword } },
|
||||
{ donor: { nickname: { contains: q.keyword } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return this.listDonations(q, where, false);
|
||||
}
|
||||
async adminDonation(publicId: string, actor: AuthContext, req: RequestContext) {
|
||||
const x = await this.findDonation(publicId);
|
||||
await this.audit.record(req, {
|
||||
action: 'OLD_ITEM_DONATION_SENSITIVE_VIEWED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_DONATION',
|
||||
resourcePublicId: publicId,
|
||||
});
|
||||
return { donation: this.mapDonation(x, true) };
|
||||
}
|
||||
|
||||
async transitionDonation(
|
||||
publicId: string,
|
||||
input: TransitionOldItemDonationDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const current = await this.findDonation(publicId),
|
||||
fp = this.fingerprint('old-item-donation-transition', { publicId, ...input });
|
||||
const replay = await this.prisma.oldItemDonationStatusHistory.findUnique({
|
||||
where: { requestId: input.clientRequestId },
|
||||
include: { donation: { include: DONATION_INCLUDE } },
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { donation: this.mapDonation(replay.donation, true), idempotentReplay: true };
|
||||
}
|
||||
if (current.revision !== input.expectedRevision) throw this.conflict();
|
||||
if (!DONATION_TRANSITIONS[current.status]?.includes(input.targetStatus))
|
||||
throw this.invalid('当前捐赠状态不能执行该操作');
|
||||
if (input.targetStatus === 'WAITING_PICKUP' && current.method !== 'PICKUP')
|
||||
throw this.invalid('投递订单不能进入待取件');
|
||||
if (input.targetStatus === 'WAITING_DROP_OFF' && current.method !== 'DROP_OFF')
|
||||
throw this.invalid('取件订单不能进入待投递');
|
||||
if (input.targetStatus === 'REJECTED' && !input.note)
|
||||
throw this.validation('驳回时必须填写原因');
|
||||
const assignee = input.assignedUserPublicId
|
||||
? await this.prisma.user.findFirst({
|
||||
where: { publicId: input.assignedUserPublicId, userType: 'STAFF', status: 'ACTIVE' },
|
||||
})
|
||||
: null;
|
||||
if (input.assignedUserPublicId && !assignee) throw this.validation('分配人员无效');
|
||||
if (input.targetStatus === 'WAITING_PICKUP' && !assignee && !current.assignedUserId)
|
||||
throw this.validation('上门取件审核通过时必须分配工作人员');
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const transitionedAt = new Date();
|
||||
const result = await tx.oldItemDonation.updateMany({
|
||||
where: { id: current.id, revision: input.expectedRevision, status: current.status },
|
||||
data: {
|
||||
status: input.targetStatus as never,
|
||||
revision: { increment: 1 },
|
||||
...(assignee ? { assignedUserId: assignee.id } : {}),
|
||||
...(input.targetStatus === 'REJECTED'
|
||||
? { rejectionReasonEncrypted: this.sensitive.encrypt(input.note!) }
|
||||
: {}),
|
||||
...(input.targetStatus === 'RECEIVED' ? { receivedAt: transitionedAt } : {}),
|
||||
...(input.targetStatus === 'COMPLETED'
|
||||
? { completedAt: transitionedAt, assistedPeople: input.assistedPeople ?? 0 }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (result.count !== 1) throw this.conflict();
|
||||
await tx.oldItemDonationStatusHistory.create({
|
||||
data: {
|
||||
donationId: current.id,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
fromStatus: current.status,
|
||||
toStatus: input.targetStatus as never,
|
||||
noteEncrypted: input.note ? this.sensitive.encrypt(input.note) : null,
|
||||
operatorUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
if (input.targetStatus === 'COMPLETED' && current.targetNeedId) {
|
||||
const need = await tx.oldItemNeed.findUniqueOrThrow({
|
||||
where: { id: current.targetNeedId },
|
||||
});
|
||||
const fulfilled = Math.min(
|
||||
need.requestedQuantity,
|
||||
need.fulfilledQuantity + current.quantity,
|
||||
);
|
||||
await tx.oldItemNeed.update({
|
||||
where: { id: need.id },
|
||||
data: {
|
||||
fulfilledQuantity: fulfilled,
|
||||
...(fulfilled >= need.requestedQuantity && need.status === 'PUBLISHED'
|
||||
? { status: 'FULFILLED', revision: { increment: 1 } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (input.targetStatus === 'COMPLETED') {
|
||||
await this.rewards.grantOnCompletion(
|
||||
tx,
|
||||
current.id,
|
||||
input.clientRequestId,
|
||||
transitionedAt,
|
||||
actor,
|
||||
req,
|
||||
);
|
||||
}
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_DONATION_STATUS_CHANGED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_DONATION',
|
||||
resourcePublicId: publicId,
|
||||
metadata: { fromStatus: current.status, toStatus: input.targetStatus },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return tx.oldItemDonation.findUniqueOrThrow({
|
||||
where: { id: current.id },
|
||||
include: DONATION_INCLUDE,
|
||||
});
|
||||
});
|
||||
return { donation: this.mapDonation(updated, true), idempotentReplay: false };
|
||||
}
|
||||
|
||||
async createNeed(input: CreateOldItemNeedDto, actor: AuthContext, req: RequestContext) {
|
||||
const fp = this.fingerprint('old-item-need-create', input);
|
||||
const replay = await this.prisma.oldItemNeed.findUnique({
|
||||
where: {
|
||||
createdById_clientRequestId: {
|
||||
createdById: actor.userId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { need: this.need(replay), idempotentReplay: true };
|
||||
}
|
||||
const x = await this.prisma.$transaction(async (tx) => {
|
||||
const n = await tx.oldItemNeed.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
clientRequestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
title: input.title.trim(),
|
||||
category: input.category as never,
|
||||
regionText: input.regionText.trim(),
|
||||
beneficiaryGroup: input.beneficiaryGroup.trim(),
|
||||
description: input.description.trim(),
|
||||
requestedQuantity: input.requestedQuantity,
|
||||
priority: input.priority ?? 0,
|
||||
sortOrder: input.sortOrder ?? 0,
|
||||
createdById: actor.userId,
|
||||
updatedById: actor.userId,
|
||||
},
|
||||
});
|
||||
await tx.oldItemNeedStatusHistory.create({
|
||||
data: {
|
||||
needId: n.id,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
toStatus: 'DRAFT',
|
||||
operatorUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_NEED_CREATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_NEED',
|
||||
resourcePublicId: n.publicId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return n;
|
||||
});
|
||||
return { need: this.need(x), idempotentReplay: false };
|
||||
}
|
||||
async updateNeed(
|
||||
publicId: string,
|
||||
input: UpdateOldItemNeedDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const n = await this.findNeed(publicId);
|
||||
if (n.status !== 'DRAFT' || n.revision !== input.expectedRevision) throw this.conflict();
|
||||
const x = await this.prisma.$transaction(async (tx) => {
|
||||
const r = await tx.oldItemNeed.updateMany({
|
||||
where: { id: n.id, status: 'DRAFT', revision: input.expectedRevision },
|
||||
data: {
|
||||
title: input.title.trim(),
|
||||
category: input.category as never,
|
||||
regionText: input.regionText.trim(),
|
||||
beneficiaryGroup: input.beneficiaryGroup.trim(),
|
||||
description: input.description.trim(),
|
||||
requestedQuantity: input.requestedQuantity,
|
||||
priority: input.priority ?? 0,
|
||||
sortOrder: input.sortOrder ?? 0,
|
||||
updatedById: actor.userId,
|
||||
revision: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (r.count !== 1) throw this.conflict();
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_NEED_UPDATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_NEED',
|
||||
resourcePublicId: publicId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return tx.oldItemNeed.findUniqueOrThrow({ where: { id: n.id } });
|
||||
});
|
||||
return { need: this.need(x) };
|
||||
}
|
||||
async transitionNeed(
|
||||
publicId: string,
|
||||
input: TransitionOldItemNeedDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const n = await this.findNeed(publicId),
|
||||
fp = this.fingerprint('old-item-need-transition', { publicId, ...input });
|
||||
const replay = await this.prisma.oldItemNeedStatusHistory.findUnique({
|
||||
where: { requestId: input.clientRequestId },
|
||||
include: { need: true },
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { need: this.need(replay.need), idempotentReplay: true };
|
||||
}
|
||||
if (
|
||||
n.revision !== input.expectedRevision ||
|
||||
!NEED_TRANSITIONS[n.status]?.includes(input.targetStatus)
|
||||
)
|
||||
throw this.invalid('当前需求状态不能执行该操作');
|
||||
const x = await this.prisma.$transaction(async (tx) => {
|
||||
const r = await tx.oldItemNeed.updateMany({
|
||||
where: { id: n.id, status: n.status, revision: n.revision },
|
||||
data: {
|
||||
status: input.targetStatus as never,
|
||||
updatedById: actor.userId,
|
||||
revision: { increment: 1 },
|
||||
...(input.targetStatus === 'PUBLISHED' ? { publishedAt: new Date() } : {}),
|
||||
},
|
||||
});
|
||||
if (r.count !== 1) throw this.conflict();
|
||||
await tx.oldItemNeedStatusHistory.create({
|
||||
data: {
|
||||
needId: n.id,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
fromStatus: n.status,
|
||||
toStatus: input.targetStatus as never,
|
||||
operatorUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_NEED_STATUS_CHANGED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_NEED',
|
||||
resourcePublicId: publicId,
|
||||
metadata: { fromStatus: n.status, toStatus: input.targetStatus },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return tx.oldItemNeed.findUniqueOrThrow({ where: { id: n.id } });
|
||||
});
|
||||
return { need: this.need(x), idempotentReplay: false };
|
||||
}
|
||||
async createDropOffPoint(
|
||||
input: CreateOldItemDropOffPointDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const x = await this.prisma.$transaction(async (tx) => {
|
||||
const p = await tx.oldItemDropOffPoint.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
...input,
|
||||
sortOrder: input.sortOrder ?? 0,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_DROP_OFF_POINT_CREATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_DROP_OFF_POINT',
|
||||
resourcePublicId: p.publicId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return p;
|
||||
});
|
||||
return {
|
||||
point: {
|
||||
publicId: x.publicId,
|
||||
name: x.name,
|
||||
regionText: x.regionText,
|
||||
addressText: x.addressText,
|
||||
openingHours: x.openingHours,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async listDonations(
|
||||
q: OldItemDonationQueryDto,
|
||||
where: Prisma.OldItemDonationWhereInput,
|
||||
sensitive: boolean,
|
||||
) {
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.oldItemDonation.count({ where }),
|
||||
this.prisma.oldItemDonation.findMany({
|
||||
where,
|
||||
include: DONATION_INCLUDE,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (q.page - 1) * q.pageSize,
|
||||
take: q.pageSize,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
items: items.map((x) => this.mapDonation(x, sensitive)),
|
||||
page: q.page,
|
||||
pageSize: q.pageSize,
|
||||
total,
|
||||
};
|
||||
}
|
||||
private async findDonation(publicId: string) {
|
||||
const x = await this.prisma.oldItemDonation.findUnique({
|
||||
where: { publicId },
|
||||
include: DONATION_INCLUDE,
|
||||
});
|
||||
if (!x) throw this.notFound('未找到该捐赠记录');
|
||||
return x;
|
||||
}
|
||||
private async findNeed(publicId: string) {
|
||||
const x = await this.prisma.oldItemNeed.findUnique({ where: { publicId } });
|
||||
if (!x) throw this.notFound('未找到该物资需求');
|
||||
return x;
|
||||
}
|
||||
private need(n: OldItemNeed): OldItemNeedItem {
|
||||
return {
|
||||
publicId: n.publicId,
|
||||
title: n.title,
|
||||
category: n.category,
|
||||
regionText: n.regionText,
|
||||
beneficiaryGroup: n.beneficiaryGroup,
|
||||
description: n.description,
|
||||
requestedQuantity: n.requestedQuantity,
|
||||
fulfilledQuantity: n.fulfilledQuantity,
|
||||
remainingQuantity: Math.max(0, n.requestedQuantity - n.fulfilledQuantity),
|
||||
priority: n.priority,
|
||||
status: n.status,
|
||||
revision: n.revision,
|
||||
publishedAt: n.publishedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
private mapDonation(d: DonationWithRelations, sensitive: boolean): OldItemDonationItem {
|
||||
return {
|
||||
publicId: d.publicId,
|
||||
donationNo: d.donationNo,
|
||||
category: d.category,
|
||||
itemCondition: d.itemCondition,
|
||||
quantity: d.quantity,
|
||||
description: sensitive ? this.sensitive.decrypt(d.descriptionEncrypted) : '物品捐赠',
|
||||
method: d.method,
|
||||
status: d.status,
|
||||
targetNeed: d.targetNeed
|
||||
? { publicId: d.targetNeed.publicId, title: d.targetNeed.title }
|
||||
: null,
|
||||
dropOffPoint: d.dropOffPoint
|
||||
? {
|
||||
publicId: d.dropOffPoint.publicId,
|
||||
name: d.dropOffPoint.name,
|
||||
regionText: d.dropOffPoint.regionText,
|
||||
addressText: d.dropOffPoint.addressText,
|
||||
openingHours: d.dropOffPoint.openingHours,
|
||||
}
|
||||
: null,
|
||||
appointmentAt: d.appointmentAt?.toISOString() ?? null,
|
||||
contactName: sensitive ? this.sensitive.decrypt(d.contactNameEncrypted) : '爱心用户',
|
||||
mobileMasked: this.maskMobile(this.sensitive.decrypt(d.mobileEncrypted)),
|
||||
address: sensitive && d.addressEncrypted ? this.sensitive.decrypt(d.addressEncrypted) : null,
|
||||
assigneeName: d.assignee?.nickname ?? null,
|
||||
rejectionReason:
|
||||
sensitive && d.rejectionReasonEncrypted
|
||||
? this.sensitive.decrypt(d.rejectionReasonEncrypted)
|
||||
: null,
|
||||
assistedPeople: d.assistedPeople,
|
||||
revision: d.revision,
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
history: d.history.map((h) => ({
|
||||
fromStatus: h.fromStatus,
|
||||
toStatus: h.toStatus,
|
||||
note: sensitive && h.noteEncrypted ? this.sensitive.decrypt(h.noteEncrypted) : null,
|
||||
createdAt: h.createdAt.toISOString(),
|
||||
})),
|
||||
imagePublicIds: d.files.map((item) => item.file.publicId),
|
||||
inventoryTrace: d.inventoryItems
|
||||
.flatMap((item) =>
|
||||
item.events.map((event) => ({
|
||||
status: event.toStatus,
|
||||
locationText: ['OUTBOUND', 'DELIVERED'].includes(event.toStatus)
|
||||
? event.locationText
|
||||
: null,
|
||||
eventAt: event.eventAt.toISOString(),
|
||||
})),
|
||||
)
|
||||
.sort((a, b) => a.eventAt.localeCompare(b.eventAt)),
|
||||
};
|
||||
}
|
||||
private validateMethod(i: CreateOldItemDonationDto) {
|
||||
if (i.method === 'PICKUP' && (!i.address || !i.appointmentAt))
|
||||
throw this.validation('上门取件需填写地址和预约时间');
|
||||
if (i.method === 'DROP_OFF' && !i.dropOffPointPublicId)
|
||||
throw this.validation('定点投递需选择投递点');
|
||||
if (i.appointmentAt && new Date(i.appointmentAt) <= new Date())
|
||||
throw this.validation('预约时间必须晚于当前时间');
|
||||
}
|
||||
private fingerprint(domain: string, value: object) {
|
||||
return this.sensitive.lookupHash(domain, JSON.stringify(value));
|
||||
}
|
||||
private donationNo() {
|
||||
return `OI-${new Date().toISOString().slice(0, 7).replace('-', '')}-${randomUUID().slice(0, 8).toUpperCase()}`;
|
||||
}
|
||||
private maskMobile(v: string) {
|
||||
return v.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
|
||||
}
|
||||
private validation(m: string) {
|
||||
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, m, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
private notFound(m: string) {
|
||||
return new ApplicationError(API_ERROR_CODES.RESOURCE_NOT_FOUND, m, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
private invalid(m: string) {
|
||||
return new ApplicationError(API_ERROR_CODES.INVALID_STATE_TRANSITION, m, HttpStatus.CONFLICT);
|
||||
}
|
||||
private conflict() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.RESOURCE_CONFLICT,
|
||||
'数据已更新,请刷新后重试',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
private duplicate() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.DUPLICATE_REQUEST,
|
||||
'请求标识已用于其他操作',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -163,7 +163,8 @@ export class CreateMediaUploadIntentDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsIn(['PROJECT_COVER', 'ACTIVITY_COVER']) businessType!: 'PROJECT_COVER' | 'ACTIVITY_COVER';
|
||||
@IsString() @Length(1, 255) filename!: string;
|
||||
@IsIn(['image/jpeg', 'image/png', 'image/webp']) mimeType!: string;
|
||||
@IsIn(['image/jpeg', 'image/png', 'image/webp']) mimeType!:
|
||||
'image/jpeg' | 'image/png' | 'image/webp';
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(10 * 1024 * 1024) size!: number;
|
||||
@IsOptional() @Matches(/^[a-fA-F0-9]{64}$/) sha256?: string;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
API_ERROR_CODES,
|
||||
type ConfirmUploadResponse,
|
||||
type CreateUploadIntentResponse,
|
||||
type CreateMediaUploadIntentRequest,
|
||||
type ServiceRecordAttachment,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
@@ -14,7 +15,6 @@ import { FilesRepository } from '../files/files.repository';
|
||||
import { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from '../files/object-storage.adapter';
|
||||
import type { File, FileBusinessType } from '../generated/prisma/client';
|
||||
import { SensitiveDataService } from '../security/sensitive-data.service';
|
||||
import type { CreateMediaUploadIntentDto } from './dto/participation.dto';
|
||||
|
||||
const EXTENSIONS: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
@@ -31,7 +31,7 @@ export class MediaService {
|
||||
) {}
|
||||
|
||||
async createIntent(
|
||||
input: CreateMediaUploadIntentDto,
|
||||
input: CreateMediaUploadIntentRequest,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<CreateUploadIntentResponse> {
|
||||
@@ -56,7 +56,9 @@ export class MediaService {
|
||||
return this.uploadResponse(previous, true);
|
||||
}
|
||||
const now = new Date();
|
||||
const objectKey = `public-covers/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extension}`;
|
||||
const prefix =
|
||||
input.businessType === 'OLD_ITEM_DONATION_IMAGE' ? 'private-old-items' : 'public-covers';
|
||||
const objectKey = `${prefix}/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extension}`;
|
||||
const file = await this.files.createPending(
|
||||
{
|
||||
publicId: randomUUID(),
|
||||
@@ -86,7 +88,11 @@ export class MediaService {
|
||||
const file = await this.files.findByPublicId(publicId);
|
||||
if (!file || file.caseId !== null || file.uploaderUserId !== actor.userId)
|
||||
throw this.notFound();
|
||||
if (file.businessType !== 'PROJECT_COVER' && file.businessType !== 'ACTIVITY_COVER') {
|
||||
if (
|
||||
file.businessType !== 'PROJECT_COVER' &&
|
||||
file.businessType !== 'ACTIVITY_COVER' &&
|
||||
file.businessType !== 'OLD_ITEM_DONATION_IMAGE'
|
||||
) {
|
||||
throw this.notFound();
|
||||
}
|
||||
if (file.status === 'AVAILABLE') return { file: this.attachment(file) };
|
||||
|
||||
@@ -19,5 +19,6 @@ import { VolunteersService } from './volunteers.service';
|
||||
AdminParticipationController,
|
||||
],
|
||||
providers: [VolunteersService, ProjectsService, ActivitiesService, MediaService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class ParticipationModule {}
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
import { ValidationPipe, type INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'node:http';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
ApiSuccessResponse,
|
||||
OldItemDonationItem,
|
||||
OldItemNeedItem,
|
||||
OldItemStats,
|
||||
OldItemInventoryItem,
|
||||
OldItemPointAccountItem,
|
||||
OldItemRewardsSummary,
|
||||
WechatLoginResponse,
|
||||
} from '@lehuo/contracts';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { PasswordService } from '../src/auth/password.service';
|
||||
import { ADMIN_SESSION_COOKIE } from '../src/auth/session.constants';
|
||||
import { WECHAT_AUTH_ADAPTER, type WechatAuthAdapter } from '../src/auth/wechat-auth.adapter';
|
||||
import { PrismaService } from '../src/database/prisma.service';
|
||||
|
||||
class OldItemsWechatAdapter implements WechatAuthAdapter {
|
||||
exchangeCode(code: string) {
|
||||
return Promise.resolve({
|
||||
openid: `old-items-openid-${code}`,
|
||||
unionid: `old-items-unionid-${code}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const data = <T>(r: { body: unknown }) => (r.body as ApiSuccessResponse<T>).data;
|
||||
function cookie(r: { headers: Record<string, unknown> }) {
|
||||
const raw = r.headers['set-cookie'];
|
||||
const values = Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : [];
|
||||
const item = values.find((x) => x.startsWith(`${ADMIN_SESSION_COOKIE}=`));
|
||||
if (!item) throw new Error('Admin cookie missing');
|
||||
return item.split(';')[0]!;
|
||||
}
|
||||
|
||||
describe('old item reuse Phases 1-3', () => {
|
||||
let app: INestApplication, prisma: PrismaService, passwords: PasswordService;
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(WECHAT_AUTH_ADAPTER)
|
||||
.useValue(new OldItemsWechatAdapter())
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }),
|
||||
);
|
||||
await app.init();
|
||||
prisma = app.get(PrismaService);
|
||||
passwords = app.get(PasswordService);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await prisma.oldItemPointEntry.deleteMany();
|
||||
await prisma.oldItemPointAccount.deleteMany();
|
||||
await prisma.oldItemCertificate.deleteMany();
|
||||
await prisma.oldItemRewardConfig.deleteMany();
|
||||
await prisma.oldItemInventoryEvent.deleteMany();
|
||||
await prisma.oldItemInventoryItem.deleteMany();
|
||||
await prisma.oldItemInventoryImport.deleteMany();
|
||||
await prisma.oldItemDonationFile.deleteMany();
|
||||
await prisma.oldItemDonationStatusHistory.deleteMany();
|
||||
await prisma.oldItemDonation.deleteMany();
|
||||
await prisma.oldItemNeedStatusHistory.deleteMany();
|
||||
await prisma.oldItemNeed.deleteMany();
|
||||
await prisma.oldItemDropOffPoint.deleteMany();
|
||||
await prisma.auditLog.deleteMany();
|
||||
await prisma.authSession.deleteMany();
|
||||
await prisma.userRole.deleteMany();
|
||||
await prisma.adminCredential.deleteMany();
|
||||
await prisma.staffProfile.deleteMany();
|
||||
await prisma.wechatIdentity.deleteMany();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await prisma.oldItemPointEntry.deleteMany();
|
||||
await prisma.oldItemPointAccount.deleteMany();
|
||||
await prisma.oldItemCertificate.deleteMany();
|
||||
await prisma.oldItemRewardConfig.deleteMany();
|
||||
await prisma.oldItemInventoryEvent.deleteMany();
|
||||
await prisma.oldItemInventoryItem.deleteMany();
|
||||
await prisma.oldItemInventoryImport.deleteMany();
|
||||
await prisma.oldItemDonationFile.deleteMany();
|
||||
await prisma.oldItemDonationStatusHistory.deleteMany();
|
||||
await prisma.oldItemDonation.deleteMany();
|
||||
await prisma.oldItemNeedStatusHistory.deleteMany();
|
||||
await prisma.oldItemNeed.deleteMany();
|
||||
await prisma.oldItemDropOffPoint.deleteMany();
|
||||
await prisma.auditLog.deleteMany();
|
||||
await prisma.authSession.deleteMany();
|
||||
await prisma.userRole.deleteMany();
|
||||
await prisma.adminCredential.deleteMany();
|
||||
await prisma.staffProfile.deleteMany();
|
||||
await prisma.wechatIdentity.deleteMany();
|
||||
await app.close();
|
||||
});
|
||||
async function admin() {
|
||||
const username = `old.${crypto.randomUUID()}`,
|
||||
password = 'correct horse battery staple',
|
||||
role = await prisma.role.findUniqueOrThrow({ where: { code: 'ADMIN' } }),
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
publicId: crypto.randomUUID(),
|
||||
userType: 'STAFF',
|
||||
nickname: '旧物管理员',
|
||||
staffProfile: { create: { displayName: '旧物管理员' } },
|
||||
adminCredential: { create: { username, passwordHash: await passwords.hash(password) } },
|
||||
},
|
||||
});
|
||||
await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } });
|
||||
return {
|
||||
publicId: user.publicId,
|
||||
cookie: cookie(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/auth/login')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.send({ username, password })
|
||||
.expect(201),
|
||||
),
|
||||
};
|
||||
}
|
||||
async function wechat(code: string) {
|
||||
const r = await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/auth/wechat/login')
|
||||
.send({ code })
|
||||
.expect(201);
|
||||
return data<WechatLoginResponse>(r).accessToken;
|
||||
}
|
||||
|
||||
it('publishes a need and completes an auditable directional pickup donation', async () => {
|
||||
const adminSession = await admin(),
|
||||
adminCookie = adminSession.cookie;
|
||||
const rewardConfigRequestId = crypto.randomUUID();
|
||||
const rewardConfigPayload = {
|
||||
clientRequestId: rewardConfigRequestId,
|
||||
certificateTitle: '旧物善用爱心证书',
|
||||
certificateMessage:
|
||||
'感谢 {recipient} 捐赠 {quantity} 件{category}物资,公益编号 {donationNo}。',
|
||||
issuerName: '乐活公益',
|
||||
legalNotice: '本证书仅记录公益参与,不作为捐赠票据、税务凭证或财产权利证明。',
|
||||
clothingPoints: 1,
|
||||
booksPoints: 3,
|
||||
householdPoints: 1,
|
||||
sportsPoints: 1,
|
||||
maxPointsPerDonation: 10,
|
||||
};
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/old-items/reward-config/activate')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send(rewardConfigPayload)
|
||||
.expect(201);
|
||||
expect(
|
||||
data<{ idempotentReplay: boolean }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/old-items/reward-config/activate')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send(rewardConfigPayload)
|
||||
.expect(201),
|
||||
).idempotentReplay,
|
||||
).toBe(true);
|
||||
const createNeedResponse = await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/old-items/needs')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
title: '社区儿童学习用品需求',
|
||||
category: 'BOOKS',
|
||||
regionText: '丹阳市云阳街道',
|
||||
beneficiaryGroup: '困境儿童',
|
||||
description: '需要内容健康、无严重破损的课外读物',
|
||||
requestedQuantity: 20,
|
||||
priority: 80,
|
||||
sortOrder: 10,
|
||||
});
|
||||
if (createNeedResponse.status !== 201)
|
||||
throw new Error(
|
||||
`create need failed: ${createNeedResponse.status} ${JSON.stringify(createNeedResponse.body)}`,
|
||||
);
|
||||
const createdNeed = data<{ need: OldItemNeedItem }>(createNeedResponse).need;
|
||||
const published = data<{ need: OldItemNeedItem }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/needs/${createdNeed.publicId}/transition`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
expectedRevision: createdNeed.revision,
|
||||
targetStatus: 'PUBLISHED',
|
||||
})
|
||||
.expect(201),
|
||||
).need;
|
||||
expect(
|
||||
data<{ items: OldItemNeedItem[] }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get('/api/v1/old-items/needs?page=1&pageSize=20')
|
||||
.expect(200),
|
||||
).items,
|
||||
).toHaveLength(1);
|
||||
const token = await wechat('donor-one'),
|
||||
requestId = crypto.randomUUID(),
|
||||
appointmentAt = new Date(Date.now() + 86_400_000).toISOString();
|
||||
const payload = {
|
||||
clientRequestId: requestId,
|
||||
category: 'BOOKS',
|
||||
itemCondition: 'GOOD',
|
||||
quantity: 6,
|
||||
description: '六本适龄儿童读物,页面完整',
|
||||
method: 'PICKUP',
|
||||
contactName: '爱心用户',
|
||||
mobile: '13812345678',
|
||||
address: '丹阳市测试路 1 号,仅供集成测试',
|
||||
appointmentAt,
|
||||
targetNeedPublicId: published.publicId,
|
||||
imagePublicIds: [],
|
||||
};
|
||||
const created = data<{ donation: OldItemDonationItem }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/old-items/donations')
|
||||
.set('authorization', `Bearer ${token}`)
|
||||
.send(payload)
|
||||
.expect(201),
|
||||
).donation;
|
||||
const replay = data<{ donation: OldItemDonationItem; idempotentReplay: boolean }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/old-items/donations')
|
||||
.set('authorization', `Bearer ${token}`)
|
||||
.send(payload)
|
||||
.expect(201),
|
||||
);
|
||||
expect(created.donationNo).toMatch(/^OI-/);
|
||||
expect(replay.idempotentReplay).toBe(true);
|
||||
let current = created;
|
||||
for (const step of [
|
||||
{ targetStatus: 'WAITING_PICKUP', assignedUserPublicId: adminSession.publicId },
|
||||
{ targetStatus: 'RECEIVED' },
|
||||
{ targetStatus: 'PROCESSING' },
|
||||
{ targetStatus: 'COMPLETED', assistedPeople: 3 },
|
||||
]) {
|
||||
current = data<{ donation: OldItemDonationItem }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/donations/${created.publicId}/transition`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
expectedRevision: current.revision,
|
||||
...step,
|
||||
})
|
||||
.expect(201),
|
||||
).donation;
|
||||
}
|
||||
expect(current.status).toBe('COMPLETED');
|
||||
expect(current.history).toHaveLength(5);
|
||||
const stats = data<OldItemStats>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get('/api/v1/old-items/stats')
|
||||
.expect(200),
|
||||
);
|
||||
expect(stats.totalItems).toBe(6);
|
||||
expect(stats.assistedPeople).toBe(3);
|
||||
const rewards = data<OldItemRewardsSummary>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get('/api/v1/old-items/me/rewards')
|
||||
.set('authorization', `Bearer ${token}`)
|
||||
.expect(200),
|
||||
);
|
||||
expect(rewards.balance).toBe(10);
|
||||
expect(rewards.certificates).toHaveLength(1);
|
||||
expect(rewards.certificates[0]?.title).toBe('旧物善用爱心证书');
|
||||
expect(rewards.entries[0]?.pointsDelta).toBe(10);
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get(`/api/v1/old-items/me/certificates/${rewards.certificates[0]!.publicId}`)
|
||||
.set('authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
const otherToken = await wechat('other-donor');
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get(`/api/v1/old-items/me/certificates/${rewards.certificates[0]!.publicId}`)
|
||||
.set('authorization', `Bearer ${otherToken}`)
|
||||
.expect(404);
|
||||
|
||||
const accountPage = data<{ items: OldItemPointAccountItem[] }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get('/api/v1/admin/old-items/reward-accounts?page=1&pageSize=20')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.expect(200),
|
||||
);
|
||||
expect(accountPage.items).toHaveLength(1);
|
||||
const account = accountPage.items[0]!;
|
||||
const adjustment = {
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
expectedRevision: account.revision,
|
||||
pointsDelta: 5,
|
||||
reason: '集成测试人工校正',
|
||||
};
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/reward-accounts/${account.userPublicId}/adjust`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send(adjustment)
|
||||
.expect(201);
|
||||
expect(
|
||||
data<{ idempotentReplay: boolean }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/reward-accounts/${account.userPublicId}/adjust`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send(adjustment)
|
||||
.expect(201),
|
||||
).idempotentReplay,
|
||||
).toBe(true);
|
||||
expect(
|
||||
data<OldItemRewardsSummary>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get('/api/v1/old-items/me/rewards')
|
||||
.set('authorization', `Bearer ${token}`)
|
||||
.expect(200),
|
||||
).balance,
|
||||
).toBe(15);
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/reward-accounts/${account.userPublicId}/adjust`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
expectedRevision: account.revision + 1,
|
||||
pointsDelta: -16,
|
||||
reason: '不允许出现负积分',
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
const reissued = data<{ certificate: { publicId: string }; idempotentReplay: boolean }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/donations/${created.publicId}/certificate`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send({ clientRequestId: crypto.randomUUID() })
|
||||
.expect(201),
|
||||
);
|
||||
expect(reissued.idempotentReplay).toBe(false);
|
||||
expect(await prisma.oldItemCertificate.count({ where: { status: 'REVOKED' } })).toBe(1);
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/certificates/${reissued.certificate.publicId}/revoke`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send({ clientRequestId: crypto.randomUUID(), reason: '集成测试撤销' })
|
||||
.expect(201);
|
||||
expect(await prisma.oldItemCertificate.count({ where: { status: 'ISSUED' } })).toBe(0);
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: {
|
||||
resourceType: {
|
||||
in: [
|
||||
'OLD_ITEM_DONATION',
|
||||
'OLD_ITEM_NEED',
|
||||
'OLD_ITEM_REWARD_CONFIG',
|
||||
'OLD_ITEM_CERTIFICATE',
|
||||
'OLD_ITEM_POINT_ENTRY',
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeGreaterThanOrEqual(12);
|
||||
});
|
||||
|
||||
it('runs the inventory ledger state machine and atomically imports and exports rows', async () => {
|
||||
const adminCookie = (await admin()).cookie;
|
||||
const requestId = crypto.randomUUID();
|
||||
const payload = {
|
||||
clientRequestId: requestId,
|
||||
category: 'CLOTHING',
|
||||
itemName: '冬季外套',
|
||||
quantity: 12,
|
||||
unit: '件',
|
||||
storageLocation: '测试仓 A-01',
|
||||
note: '集成测试入库',
|
||||
};
|
||||
const createdResponse = await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/old-items/inventory')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send(payload)
|
||||
.expect(201);
|
||||
let current = data<{ item: OldItemInventoryItem }>(createdResponse).item;
|
||||
expect(current.inventoryNo).toMatch(/^INV-/);
|
||||
expect(current.events).toHaveLength(1);
|
||||
expect(
|
||||
data<{ idempotentReplay: boolean }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/old-items/inventory')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send(payload)
|
||||
.expect(201),
|
||||
).idempotentReplay,
|
||||
).toBe(true);
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/inventory/${current.publicId}/transition`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
expectedRevision: current.revision,
|
||||
targetStatus: 'DISINFECTED',
|
||||
})
|
||||
.expect(409);
|
||||
for (const step of [
|
||||
{ targetStatus: 'SORTED' },
|
||||
{ targetStatus: 'DISINFECTED' },
|
||||
{ targetStatus: 'OUTBOUND', destinationRegion: '丹阳市云阳街道' },
|
||||
{ targetStatus: 'DELIVERED', assistedPeople: 5 },
|
||||
]) {
|
||||
current = data<{ item: OldItemInventoryItem }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/inventory/${current.publicId}/transition`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
expectedRevision: current.revision,
|
||||
...step,
|
||||
})
|
||||
.expect(201),
|
||||
).item;
|
||||
}
|
||||
expect(current.status).toBe('DELIVERED');
|
||||
expect(current.events.map((x) => x.status)).toEqual([
|
||||
'IN_STOCK',
|
||||
'SORTED',
|
||||
'DISINFECTED',
|
||||
'OUTBOUND',
|
||||
'DELIVERED',
|
||||
]);
|
||||
|
||||
const importId = crypto.randomUUID();
|
||||
const importPayload = {
|
||||
clientRequestId: importId,
|
||||
originalFilename: '物资入库.xlsx',
|
||||
rows: [
|
||||
{
|
||||
category: 'BOOKS',
|
||||
itemName: '儿童读物',
|
||||
quantity: 20,
|
||||
unit: '本',
|
||||
storageLocation: '测试仓 B-01',
|
||||
},
|
||||
{
|
||||
category: 'SPORTS',
|
||||
itemName: '跳绳',
|
||||
quantity: 8,
|
||||
unit: '根',
|
||||
storageLocation: '测试仓 B-02',
|
||||
},
|
||||
],
|
||||
};
|
||||
const imported = data<{ items: OldItemInventoryItem[]; idempotentReplay: boolean }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/old-items/inventory/import')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send(importPayload)
|
||||
.expect(201),
|
||||
);
|
||||
expect(imported.items).toHaveLength(2);
|
||||
expect(
|
||||
data<{ idempotentReplay: boolean }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/old-items/inventory/import')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send(importPayload)
|
||||
.expect(201),
|
||||
).idempotentReplay,
|
||||
).toBe(true);
|
||||
const exported = data<{ rows: unknown[] }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get('/api/v1/admin/old-items/inventory-export/file?page=1&pageSize=100')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.expect(200),
|
||||
);
|
||||
expect(exported.rows).toHaveLength(3);
|
||||
expect(await prisma.oldItemInventoryEvent.count()).toBe(7);
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: { resourceType: { startsWith: 'OLD_ITEM_INVENTORY' } },
|
||||
}),
|
||||
).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user