diff --git a/api/alembic/versions/0062_vsp_clear_closed_at.py b/api/alembic/versions/0062_vsp_clear_closed_at.py new file mode 100644 index 0000000..626c00c --- /dev/null +++ b/api/alembic/versions/0062_vsp_clear_closed_at.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0062" +down_revision = "0061" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0062_vsp_clear_closed_at.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0062_vsp_clear_closed_at.sql b/api/alembic/versions/sql/0062_vsp_clear_closed_at.sql new file mode 100644 index 0000000..5341807 --- /dev/null +++ b/api/alembic/versions/sql/0062_vsp_clear_closed_at.sql @@ -0,0 +1,195 @@ +-- AURORA-1886: p_clear_closed_at позволяет снять дату закрытия ВСП (NULL в p_closed_at означает «не менять»). +DROP FUNCTION v3.upd_vsp(int4, int4, varchar, varchar, varchar, date, varchar, int4, numeric, date, bool, bool, varchar, int4, varchar, text, varchar, date); + +CREATE FUNCTION v3.upd_vsp(p_vsp_id integer, p_branch_id integer DEFAULT NULL::integer, p_reg_number character varying DEFAULT NULL::character varying, p_address character varying DEFAULT NULL::character varying, p_format character varying DEFAULT NULL::character varying, p_opened_at date DEFAULT NULL::date, p_placement_type character varying DEFAULT NULL::character varying, p_staff_count integer DEFAULT NULL::integer, p_total_area numeric DEFAULT NULL::numeric, p_closed_at date DEFAULT NULL::date, p_is_active boolean DEFAULT NULL::boolean, p_is_deleted boolean DEFAULT NULL::boolean, p_system_code character varying DEFAULT NULL::character varying, p_updated_by integer DEFAULT NULL::integer, p_vsp_type character varying DEFAULT NULL::character varying, p_notes text DEFAULT NULL::text, p_rent_contract_num character varying DEFAULT NULL::character varying, p_rent_end_date date DEFAULT NULL::date, p_clear_closed_at boolean DEFAULT false) + RETURNS void + LANGUAGE plpgsql +AS $function$ +DECLARE + v_old RECORD; + v_changes JSONB := '{}'::jsonb; + + v_branch_name_before VARCHAR; + v_branch_name_after VARCHAR; + + v_actor_id INT; + v_actor_email VARCHAR; + v_actor_full_name VARCHAR; +BEGIN + SELECT v.branch_id, v.reg_number, v.address, v.format, v.opened_at, + v.placement_type, v.staff_count, v.total_area, v.closed_at, + v.is_active, v.is_deleted, v.system_code, + v.vsp_type, v.notes, v.rent_contract_num, v.rent_end_date + INTO v_old + FROM v3.vsp v + WHERE v.id = p_vsp_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'vsp #% не существует', p_vsp_id; + END IF; + + SELECT ou.title + INTO v_branch_name_before + FROM v3.org_unit ou + WHERE ou.id = v_old.branch_id; + + v_branch_name_after := v_branch_name_before; + + v_actor_id := COALESCE(p_updated_by, v3.current_user_id()); + IF v_actor_id IS NOT NULL THEN + SELECT u.email, u.full_name + INTO v_actor_email, v_actor_full_name + FROM v3.app_user u + WHERE u.id = v_actor_id; + END IF; + + IF p_branch_id IS NOT NULL AND p_branch_id IS DISTINCT FROM v_old.branch_id THEN + SELECT ou.title INTO v_branch_name_after + FROM v3.org_unit ou + WHERE ou.id = p_branch_id; + + UPDATE v3.vsp SET branch_id = p_branch_id WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object( + 'branch_id', jsonb_build_object( + 'before', v3._diff_val(v_old.branch_id), + 'after', v3._diff_val(p_branch_id) + ), + 'branch_name', jsonb_build_object( + 'before', v3._diff_val(v_branch_name_before), + 'after', v3._diff_val(v_branch_name_after) + ) + ); + END IF; + + IF p_reg_number IS NOT NULL AND p_reg_number IS DISTINCT FROM v_old.reg_number THEN + UPDATE v3.vsp SET reg_number = p_reg_number WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object('reg_number', + jsonb_build_object('before', v3._diff_val(v_old.reg_number), + 'after', v3._diff_val(p_reg_number))); + END IF; + + IF p_address IS NOT NULL AND p_address IS DISTINCT FROM v_old.address THEN + UPDATE v3.vsp SET address = p_address WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object('address', + jsonb_build_object('before', v3._diff_val(v_old.address), + 'after', v3._diff_val(p_address))); + END IF; + + IF p_format IS NOT NULL AND p_format IS DISTINCT FROM v_old.format THEN + UPDATE v3.vsp SET format = p_format WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object('format', + jsonb_build_object('before', v3._diff_val(v_old.format), + 'after', v3._diff_val(p_format))); + END IF; + + IF p_opened_at IS NOT NULL AND p_opened_at IS DISTINCT FROM v_old.opened_at THEN + UPDATE v3.vsp SET opened_at = p_opened_at WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object('opened_at', + jsonb_build_object('before', v3._diff_val(v_old.opened_at), + 'after', v3._diff_val(p_opened_at))); + END IF; + + IF p_placement_type IS NOT NULL AND p_placement_type IS DISTINCT FROM v_old.placement_type THEN + UPDATE v3.vsp SET placement_type = p_placement_type WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object('placement_type', + jsonb_build_object('before', v3._diff_val(v_old.placement_type), + 'after', v3._diff_val(p_placement_type))); + END IF; + + IF p_staff_count IS NOT NULL AND p_staff_count IS DISTINCT FROM v_old.staff_count THEN + UPDATE v3.vsp SET staff_count = p_staff_count WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object('staff_count', + jsonb_build_object('before', v3._diff_val(v_old.staff_count), + 'after', v3._diff_val(p_staff_count))); + END IF; + + IF p_total_area IS NOT NULL AND p_total_area IS DISTINCT FROM v_old.total_area THEN + UPDATE v3.vsp SET total_area = p_total_area WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object('total_area', + jsonb_build_object('before', v3._diff_val(v_old.total_area), + 'after', v3._diff_val(p_total_area))); + END IF; + + IF p_clear_closed_at AND p_closed_at IS NULL AND v_old.closed_at IS NOT NULL THEN + UPDATE v3.vsp SET closed_at = NULL WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object('closed_at', + jsonb_build_object('before', v3._diff_val(v_old.closed_at), + 'after', v3._diff_val(NULL::date))); + ELSIF p_closed_at IS NOT NULL AND p_closed_at IS DISTINCT FROM v_old.closed_at THEN + UPDATE v3.vsp SET closed_at = p_closed_at WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object('closed_at', + jsonb_build_object('before', v3._diff_val(v_old.closed_at), + 'after', v3._diff_val(p_closed_at))); + END IF; + + IF p_is_active IS NOT NULL AND p_is_active IS DISTINCT FROM v_old.is_active THEN + UPDATE v3.vsp SET is_active = p_is_active WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object('is_active', + jsonb_build_object('before', v3._diff_val(v_old.is_active), + 'after', v3._diff_val(p_is_active))); + END IF; + + IF p_is_deleted IS NOT NULL AND p_is_deleted IS DISTINCT FROM v_old.is_deleted THEN + UPDATE v3.vsp SET is_deleted = p_is_deleted WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object('is_deleted', + jsonb_build_object('before', v3._diff_val(v_old.is_deleted), + 'after', v3._diff_val(p_is_deleted))); + END IF; + + IF p_system_code IS NOT NULL AND p_system_code IS DISTINCT FROM v_old.system_code THEN + UPDATE v3.vsp SET system_code = p_system_code WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object('system_code', + jsonb_build_object('before', v3._diff_val(v_old.system_code), + 'after', v3._diff_val(p_system_code))); + END IF; + + IF p_vsp_type IS NOT NULL AND p_vsp_type IS DISTINCT FROM v_old.vsp_type THEN + UPDATE v3.vsp SET vsp_type = p_vsp_type WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object('vsp_type', + jsonb_build_object('before', v3._diff_val(v_old.vsp_type), + 'after', v3._diff_val(p_vsp_type))); + END IF; + + IF p_notes IS NOT NULL AND p_notes IS DISTINCT FROM v_old.notes THEN + UPDATE v3.vsp SET notes = p_notes WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object('notes', + jsonb_build_object('before', v3._diff_val(v_old.notes), + 'after', v3._diff_val(p_notes))); + END IF; + + IF p_rent_contract_num IS NOT NULL AND p_rent_contract_num IS DISTINCT FROM v_old.rent_contract_num THEN + UPDATE v3.vsp SET rent_contract_num = p_rent_contract_num WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object('rent_contract_num', + jsonb_build_object('before', v3._diff_val(v_old.rent_contract_num), + 'after', v3._diff_val(p_rent_contract_num))); + END IF; + + IF p_rent_end_date IS NOT NULL AND p_rent_end_date IS DISTINCT FROM v_old.rent_end_date THEN + UPDATE v3.vsp SET rent_end_date = p_rent_end_date WHERE id = p_vsp_id; + v_changes := v_changes || jsonb_build_object('rent_end_date', + jsonb_build_object('before', v3._diff_val(v_old.rent_end_date), + 'after', v3._diff_val(p_rent_end_date))); + END IF; + + IF v_changes <> '{}'::jsonb THEN + UPDATE v3.vsp + SET updated_at = now(), + updated_by = p_updated_by + WHERE id = p_vsp_id; + + PERFORM v3.log_event( + 'VSP_UPDATE', 'VSP', + jsonb_build_object( + 'vsp_id', p_vsp_id, + 'entity_id', p_vsp_id, + 'branch_id', COALESCE(p_branch_id, v_old.branch_id), + 'branch_name', COALESCE(v_branch_name_after, v_branch_name_before), + 'changes', v_changes, + 'user_email', v_actor_email, + 'user_full_name', v_actor_full_name + ), null, null, COALESCE(p_branch_id, v_old.branch_id) + ); + END IF; +END; +$function$ +; diff --git a/api/src/repository/vsp_repository.py b/api/src/repository/vsp_repository.py index 1cd5caa..f4309ab 100644 --- a/api/src/repository/vsp_repository.py +++ b/api/src/repository/vsp_repository.py @@ -207,6 +207,7 @@ class VSPRepository: params = {"p_vsp_id": vsp.id, "p_updated_by": updated_by} for field, param in FIELD_TO_PARAM.items(): params[param] = data.get(field) + params["p_clear_closed_at"] = "closed_at" in data and data["closed_at"] is None await self.db.execute( text(""" @@ -215,7 +216,7 @@ class VSPRepository: :p_format, :p_opened_at, :p_placement_type, :p_staff_count, :p_total_area, :p_closed_at, :p_is_active, :p_is_deleted, :p_system_code, :p_updated_by, :p_vsp_type, :p_notes, - :p_rent_contract_num, :p_rent_end_date + :p_rent_contract_num, :p_rent_end_date, :p_clear_closed_at ) """), params, diff --git a/api/src/services/vsp_service.py b/api/src/services/vsp_service.py index 63e7847..737123d 100644 --- a/api/src/services/vsp_service.py +++ b/api/src/services/vsp_service.py @@ -267,6 +267,9 @@ class VSPService: effective_close_date = data.get("closed_at", vsp.closed_at) if effective_close_date is not None and effective_close_date <= date.today(): data["is_active"] = False + elif "closed_at" in data: + # дату сняли или перенесли на будущее: ВСП возвращается в действующие + data["is_active"] = True try: return await self.vsp_repo.update( diff --git a/api/tests/integration/test_vsp.py b/api/tests/integration/test_vsp.py index 4ea505c..5b3670c 100644 --- a/api/tests/integration/test_vsp.py +++ b/api/tests/integration/test_vsp.py @@ -353,3 +353,82 @@ def test_vsp_update_past_close_date_deactivates(client, admin_tokens, auth_heade listing = client.get("/api/v1/dict/info", headers=auth_headers(admin_tokens)) assert listing.status_code == 200 assert vsp_id not in [item["id"] for item in listing.json()["result"]] + + +def _put_vsp(client, admin_tokens, auth_headers, vsp_id, **body): + response = client.put( + f"/api/v1/dict/info/{vsp_id}", json=body, headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + return response.json()["result"] + + +def _closed_vsp(client, admin_tokens, auth_headers) -> int: + from datetime import date, timedelta + + vsp_id = _create_vsp(client, admin_tokens, auth_headers, open_date="2020-01-01") + result = _put_vsp( + client, admin_tokens, auth_headers, vsp_id, + close_date=(date.today() - timedelta(days=1)).isoformat(), + ) + assert result["is_active"] is False + return vsp_id + + +def test_vsp_update_clear_close_date_reopens(client, admin_tokens, auth_headers): + vsp_id = _closed_vsp(client, admin_tokens, auth_headers) + + result = _put_vsp(client, admin_tokens, auth_headers, vsp_id, close_date=None) + + assert result["close_date"] is None + assert result["is_active"] is True + listing = client.get("/api/v1/dict/info", headers=auth_headers(admin_tokens)) + assert vsp_id in [item["id"] for item in listing.json()["result"]] + + +def test_vsp_update_future_close_date_reopens(client, admin_tokens, auth_headers): + from datetime import date, timedelta + + vsp_id = _closed_vsp(client, admin_tokens, auth_headers) + future = (date.today() + timedelta(days=30)).isoformat() + + result = _put_vsp(client, admin_tokens, auth_headers, vsp_id, close_date=future) + + assert result["close_date"] == future + assert result["is_active"] is True + + +def test_vsp_update_without_close_date_keeps_it(client, admin_tokens, auth_headers): + from datetime import date, timedelta + + vsp_id = _closed_vsp(client, admin_tokens, auth_headers) + yesterday = (date.today() - timedelta(days=1)).isoformat() + + result = _put_vsp(client, admin_tokens, auth_headers, vsp_id, address="AURORA-1886 new") + + assert result["close_date"] == yesterday + assert result["is_active"] is False + + +def test_vsp_clear_close_date_is_audited(client, admin_tokens, auth_headers, db_session): + from sqlalchemy import text + + vsp_id = _closed_vsp(client, admin_tokens, auth_headers) + _put_vsp(client, admin_tokens, auth_headers, vsp_id, close_date=None) + + async def _last_changes(): + row = ( + await db_session.execute( + text( + "SELECT event_data->'changes' FROM v3.audit_log " + "WHERE event = 'VSP_UPDATE' AND (event_data->>'vsp_id')::int = :id " + "ORDER BY id DESC LIMIT 1" + ), + {"id": vsp_id}, + ) + ).first() + return row[0] + + changes = client.portal.call(_last_changes) + assert changes["closed_at"]["before"] is not None + assert changes["closed_at"]["after"] is None diff --git a/api/tests/unit/test_vsp_service_close_date.py b/api/tests/unit/test_vsp_service_close_date.py index e29814b..1de3674 100644 --- a/api/tests/unit/test_vsp_service_close_date.py +++ b/api/tests/unit/test_vsp_service_close_date.py @@ -34,7 +34,7 @@ async def test_update_past_close_date_sets_inactive(): assert data["is_active"] is False -async def test_update_future_close_date_keeps_active_key_absent(): +async def test_update_future_close_date_sets_active(): vsp = MagicMock(closed_at=None) service = _service(vsp) payload = VSPUpdate(close_date=date.today() + timedelta(days=30)) @@ -42,4 +42,16 @@ async def test_update_future_close_date_keeps_active_key_absent(): await service.update(vsp_id=1, payload=payload, user=_admin()) data = service.vsp_repo.update.await_args.kwargs["data"] - assert "is_active" not in data + assert data["is_active"] is True + + +async def test_update_null_close_date_sets_active(): + vsp = MagicMock(closed_at=date.today() - timedelta(days=1)) + service = _service(vsp) + payload = VSPUpdate(close_date=None) + + await service.update(vsp_id=1, payload=payload, user=_admin()) + + data = service.vsp_repo.update.await_args.kwargs["data"] + assert data["closed_at"] is None + assert data["is_active"] is True